WordPress REST API: practical use cases for custom post types
How to use WordPress REST API with custom post types in production. Concrete examples: headless CMS, mobile app backend, AI integrations. PHP + JS code ready to use.
WordPress REST API: practical use cases for custom post types
WordPress REST API is an underrated tool. Many people treat WP as "just a blog CMS", while under the hood it is a fully functional backend with authentication, permissions, custom post types and meta fields. This post is concrete use cases, not dry docs.
Custom post type with REST API support
First step: when registering the CPT add 'show_in_rest' => true and
'rest_base' => 'projekty':
// functions.php or your own plugin
add_action('init', function () {
register_post_type('projekt', [
'labels' => [
'name' => 'Projekty',
'singular_name' => 'Projekt',
],
'public' => true,
'show_in_rest' => true, // KEY: exposes in REST API
'rest_base' => 'projekty', // /wp/v2/projekty
'rest_controller_class' => 'WP_REST_Posts_Controller',
'supports' => ['title', 'editor', 'thumbnail', 'excerpt', 'custom-fields'],
'has_archive' => true,
'menu_icon' => 'dashicons-portfolio',
'rewrite' => ['slug' => 'projekty'],
]);
});
From now on the endpoint /wp-json/wp/v2/projekty returns an array
of projects as JSON. Each project is a full post object with fields:
id, title (rendered + raw), content, excerpt, featured_media, meta.
Adding custom fields to REST API
By default custom fields (post meta) are not visible in REST. You have to register them explicitly:
// Register meta box + REST exposure
add_action('init', function () {
register_post_meta('projekt', 'klient', [
'type' => 'string',
'single' => true,
'show_in_rest' => true,
'sanitize_callback' => 'sanitize_text_field',
'auth_callback' => function () {
return current_user_can('edit_posts');
},
]);
register_post_meta('projekt', 'budzet', [
'type' => 'number',
'single' => true,
'show_in_rest' => true,
'sanitize_callback' => 'absint',
]);
register_post_meta('projekt', 'data_realizacji', [
'type' => 'string',
'single' => true,
'show_in_rest' => true,
'sanitize_callback' => function ($value) {
// ISO 8601 date validation
$d = DateTime::createFromFormat('Y-m-d', $value);
return $d && $d->format('Y-m-d') === $value ? $value : '';
},
]);
});
Three things to remember:
sanitize_callbackβ always. Without it a user can type anything into the meta field (XSS, SQL injection in edge cases).auth_callbackβ who can edit this field. By default nobody. Returnstruewhen the user has permissions.- Format validation β for dates, URLs, JSONs. Better to return
an empty string than to accept
2025-13-99.
Custom endpoint with validation and permissions
Sometimes you need an endpoint that does not map 1:1 to a CPT. E.g. "give me project statistics":
add_action('rest_api_init', function () {
register_rest_route('custom/v1', '/projekt/(?P<id>\d+)/stats', [
'methods' => 'GET',
'callback' => 'get_projekt_stats',
'permission_callback' => function () {
return current_user_can('read');
},
'args' => [
'id' => [
'validate_callback' => function ($param) {
return is_numeric($param);
},
],
],
]);
});
function get_projekt_stats(WP_REST_Request $request) {
$projekt_id = absint($request['id']);
$projekt = get_post($projekt_id);
if (!$projekt || $projekt->post_type !== 'projekt') {
return new WP_Error('not_found', 'Project not found', ['status' => 404]);
}
// Cache for 1h
$cache_key = "projekt_stats_{$projekt_id}";
$stats = wp_cache_get($cache_key);
if ($stats === false) {
$stats = [
'views' => (int) get_post_meta($projekt_id, 'views', true),
'leads' => (int) get_post_meta($projekt_id, 'leads', true),
'conversion_rate' => $this->calc_conversion($projekt_id),
'tasks_total' => $this->count_tasks($projekt_id),
'tasks_done' => $this->count_tasks($projekt_id, 'done'),
];
wp_cache_set($cache_key, $stats, '', HOUR_IN_SECONDS);
}
return new WP_REST_Response($stats, 200);
}
Key points:
-
permission_callbackβ separate fromcallback. WordPress checks permissions BEFORE running the logic. Without it anyone can hit the endpoint. -
validate_callbackin args β URL parameter validation. Without it someone can hit/projekt/abc/statsand get a 500 error. -
Cache β
wp_cache_get+wp_cache_set. WordPress uses object cache (transient by default, with Redis/Memcached β fast). -
WP_Errorwith status code β never returnnullor an empty array. Return meaningful errors with HTTP codes (404, 403, 500).
Adding computed fields to standard endpoints
Sometimes you want to extend an existing endpoint with an extra
field, e.g. "related_projects" in the /wp/v2/projekty response:
add_action('rest_api_init', function () {
register_rest_field('projekt', 'related_projects', [
'get_callback' => 'get_related_projects',
'update_callback' => null, // read-only
'schema' => [
'description' => 'List of 3 related projects',
'type' => 'array',
'items' => ['type' => 'object'],
],
]);
});
function get_related_projects($post) {
$tags = wp_get_post_tags($post['id'], ['fields' => 'ids']);
if (empty($tags)) return [];
$related = get_posts([
'post_type' => 'projekt',
'post__not_in' => [$post['id']],
'posts_per_page' => 3,
'tag__in' => $tags,
'fields' => 'ids',
]);
return array_map(function ($id) {
$p = get_post($id);
return [
'id' => $id,
'title' => $p->post_title,
'permalink' => get_permalink($id),
'klient' => get_post_meta($id, 'klient', true),
];
}, $related);
}
Now every call to /wp/v2/projekty/123 returns an extra
related_projects field in the JSON. The frontend can render it
without additional requests.
Authentication for external integrations
For system-to-system (e.g. Next.js frontend β WP backend) do not use cookie auth. Use Application Passwords (WP 5.6+):
# Generate password in WP Admin: Users β Profile β Application Passwords
# WordPress gives you a 24-char password, e.g. "abcd EFGH ijkl MNOP qrst UVWX"
# Request with auth
curl -u "admin:abcd EFGH ijkl MNOP qrst UVWX" \
https://example.com/wp-json/wp/v2/projekty
In Node.js / Next.js:
const username = process.env.WP_USERNAME!;
const appPassword = process.env.WP_APP_PASSWORD!;
const baseUrl = process.env.WP_BASE_URL!;
const auth = Buffer.from(`${username}:${appPassword}`).toString('base64');
async function fetchProjects() {
const res = await fetch(`${baseUrl}/wp-json/wp/v2/projekty?per_page=100`, {
headers: { Authorization: `Basic ${auth}` },
next: { revalidate: 60 }, // ISR in Next.js
});
return res.json();
}
Three security layers I deploy in every project:
- HTTPS only β without it the password travels in plain text.
- Application Password scope β one password per integration, not the main one. If it leaks β you revoke one, not all.
- Rate limiting in Cloudflare β see the previous post on Cloudflare. Without it bots can scrape all your data.
Use case 1: Headless CMS for Next.js
WordPress as the backend, Next.js as the frontend. Full control over UX, SSG for SEO, React for interactivity:
// app/projekty/page.tsx
import { headers } from 'next/headers';
async function getProjects() {
const auth = Buffer.from(
`${process.env.WP_USERNAME}:${process.env.WP_APP_PASSWORD}`
).toString('base64');
const res = await fetch(
`${process.env.WP_BASE_URL}/wp-json/wp/v2/projekty?_embed&per_page=50`,
{
headers: { Authorization: `Basic ${auth}` },
next: { revalidate: 300 }, // regenerate every 5 min
}
);
if (!res.ok) throw new Error('Failed to fetch projects');
return res.json();
}
export default async function ProjektyPage() {
const projects = await getProjects();
return (
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{projects.map((p) => (
<article key={p.id} className="border rounded p-4">
<h2 dangerouslySetInnerHTML={{ __html: p.title.rendered }} />
<div dangerouslySetInnerHTML={{ __html: p.excerpt.rendered }} />
<span className="text-sm text-gray-500">{p.klient}</span>
</article>
))}
</div>
);
}
_embed in the URL returns related resources in one request:
featured image (full URLs in different sizes), author (with avatar),
terms (tags, categories). Without _embed you would have to do N extra
requests for the featured_media of each project.
Use case 2: AI integration - generating project description
My workflow for clients: AI generates a draft of the description, the
editor accepts it. The endpoint POST /custom/v1/projekt/<id>/ai-description:
add_action('rest_api_init', function () {
register_rest_route('custom/v1', '/projekt/(?P<id>\d+)/ai-description', [
'methods' => 'POST',
'callback' => 'generate_ai_description',
'permission_callback' => function () {
return current_user_can('edit_posts');
},
]);
});
function generate_ai_description(WP_REST_Request $request) {
$projekt_id = absint($request['id']);
$projekt = get_post($projekt_id);
if (!$projekt) {
return new WP_Error('not_found', 'Project not found', ['status' => 404]);
}
$klient = get_post_meta($projekt_id, 'klient', true);
$budzet = get_post_meta($projekt_id, 'budzet', true);
$prompt = "Write a 3-sentence description of an IT project in English.\n";
$prompt .= "Client: $klient\n";
$prompt .= "Budget: $budzet PLN\n";
$prompt .= "Title: {$projekt->post_title}\n";
$prompt .= "Current description: {$projekt->post_excerpt}\n";
$response = openai_chat_completion([
'model' => 'gpt-4o-mini',
'messages' => [
['role' => 'system', 'content' => 'You are an IT project description editor.'],
['role' => 'user', 'content' => $prompt],
],
'temperature' => 0.7,
]);
if (is_wp_error($response)) {
return $response;
}
// Save to a custom field, do NOT overwrite post_content
update_post_meta($projekt_id, 'ai_description_draft', $response['content']);
return new WP_REST_Response([
'draft' => $response['content'],
'cost_usd' => $response['cost'],
], 200);
}
This endpoint gives the editor a draft to accept, instead of
overwriting content right away. Security: only a user with
edit_posts permission can call it.
Use case 3: Mobile app backend
A simple mobile app (React Native) for the team β fetches active projects, lets you add a task. Endpoints with auth:
add_action('rest_api_init', function () {
register_rest_route('mobile/v1', '/projekty', [
'methods' => 'GET',
'callback' => 'mobile_get_projekty',
'permission_callback' => 'mobile_check_token',
]);
register_rest_route('mobile/v1', '/projekty/(?P<id>\d+)/tasks', [
'methods' => 'POST',
'callback' => 'mobile_create_task',
'permission_callback' => 'mobile_check_token',
]);
});
function mobile_check_token(WP_REST_Request $request) {
$token = $request->get_header('X-Mobile-Token');
if (!$token) return new WP_Error('no_token', 'Missing token', ['status' => 401]);
// HMAC: token = HMAC(secret, timestamp)
$timestamp = $request->get_header('X-Mobile-Timestamp');
$expected = hash_hmac('sha256', $timestamp, MOBILE_SECRET);
if (!hash_equals($expected, $token)) {
return new WP_Error('invalid_token', 'Invalid token', ['status' => 401]);
}
return true;
}
HMAC in the header: the app sends X-Mobile-Token (HMAC with secret)
and X-Mobile-Timestamp (Unix time). The backend verifies the HMAC,
checks the timestamp (max 5 min diff to avoid replay attacks). Simpler
than OAuth, more secure than API key in the URL.
Debugging and testing
# Test endpoint with auth
curl -u "admin:abcd EFGH ijkl MNOP qrst UVWX" \
https://example.com/wp-json/wp/v2/projekty?per_page=5
# Check available endpoints
curl https://example.com/wp-json/ | jq
# Check custom fields in the response
curl https://example.com/wp-json/wp/v2/projekty/123 | jq '.klient, .budzet'
# Check response headers (rate limit, cache)
curl -I https://example.com/wp-json/wp/v2/projekty
The Query Monitor plugin shows in WP admin: how many SQL queries per request, how much time, which endpoints are slow. Without it you optimize blindly.
What is next
- Cloudflare for WordPress β full optimization
- Multilingual WordPress with Polylang
- Multi-agent AI systems in practice (AI integration with WP via REST)
If you have a WordPress project you want to move to a headless architecture or extend with AI features β get in touch. Delivery: 2-4 weeks, starting from 4000 PLN.
NajczΔΕciej zadawane pytania
Is WordPress REST API stable in production?
Headless WordPress or classic WordPress with AJAX?
How do I secure a custom endpoint against unauthorized access?
Can I return related posts in a single request?
Related posts
- devops
Cloudflare for WordPress: full performance and security optimization
How to configure Cloudflare (Free/Pro) for WordPress step by step: cache, APO, WAF, Bot Fight Mode, page rules, CDN. Real measurement results and configuration for Poland.
8 min - wordpress
Multilingual WordPress with Polylang: practical implementation guide
How to implement multilingual WordPress with Polylang: configuration, SEO, hreflang, automatic translations, ACF and WooCommerce integration. Real pitfalls, code, settings.
8 min