All posts
wordpress

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.

8 min readUpdated: June 24, 2026

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. Returns true when 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:

  1. permission_callback β€” separate from callback. WordPress checks permissions BEFORE running the logic. Without it anyone can hit the endpoint.

  2. validate_callback in args β€” URL parameter validation. Without it someone can hit /projekt/abc/stats and get a 500 error.

  3. Cache β€” wp_cache_get + wp_cache_set. WordPress uses object cache (transient by default, with Redis/Memcached β€” fast).

  4. WP_Error with status code β€” never return null or 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:

  1. HTTPS only β€” without it the password travels in plain text.
  2. Application Password scope β€” one password per integration, not the main one. If it leaks β€” you revoke one, not all.
  3. 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

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.

Tags:#wordpress#rest-api#php#headless#custom-post-types

NajczΔ™Ε›ciej zadawane pytania

Is WordPress REST API stable in production?
Yes, since version 4.4 (2015). I have been using it in production since 2017 β€” hundreds of thousands of requests per day without issues. WP 5.5+ has a separate 'wp/v2' namespace (stable contract). Versions 4.x used 'wp/v1' β€” abandoned, but the old endpoints still work.
Headless WordPress or classic WordPress with AJAX?
Headless (Next.js/Astro frontend + WP backend) β€” for large projects (e-commerce, SaaS, portals with thousands of pages), where the frontend must be fast (React/SSG). Classic WP with AJAX β€” for blogs, corporate sites, small shops. The extra complexity of headless pays off only above 50k page views per month or when you need AI/ML on the frontend.
How do I secure a custom endpoint against unauthorized access?
Three layers: (1) WordPress nonces for authenticated users (check login + permissions), (2) Application Passwords for external integrations (WP 5.6+), (3) custom token in header (HMAC) for system-to-system. Never leave an endpoint without checking `current_user_can()`.
Can I return related posts in a single request?
Yes β€” register_rest_field adds a field to the response. You can add 'related_posts' to the /wp/v2/posts endpoint, and WP returns an array of 5 related posts in one JSON. You avoid N+1 queries and request chaining from the frontend.

Related posts