Week 22: WordPress REST API & Headless WordPress

Every post type registered with show_in_rest => true since Week 19 has quietly been exposing a full JSON API this whole time. This week uses it properly — the built-in endpoints, custom routes for the inquiries feature, authentication, and the "headless" pattern: WordPress as a pure content API behind a separate frontend.

Module 19 of 28 Week 22 of 32 ~3–4 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Use WordPress's built-in REST API endpoints for posts & custom post types
  • Register custom REST routes with proper permission callbacks
  • Explain what "headless WordPress" means & when it's the right architecture

1. Built-in Endpoints

Every modern WordPress install exposes a REST API at /wp-json/ with zero extra setup — posts, pages, and any post type registered with show_in_rest => true (including Week 19's project CPT) are already reachable as JSON:

terminal — exploring it
curl http://localhost:8000/wp-json/wp/v2/posts
curl http://localhost:8000/wp-json/wp/v2/posts/1
curl http://localhost:8000/wp-json/wp/v2/project        # your CPT, automatically
curl "http://localhost:8000/wp-json/wp/v2/posts?per_page=5&search=hello"
a response, shortened
[
  {
    "id": 1,
    "title": { "rendered": "Hello from wp-cli" },
    "content": { "rendered": "<p>...</p>" },
    "excerpt": { "rendered": "<p>...</p>" },
    "date": "2026-01-15T10:00:00",
    "link": "http://localhost:8000/hello-from-wp-cli/"
  }
]

This is a genuinely different situation from Week 15's Laravel API — there, every route and response shape was deliberately built. Here, the API already exists the moment a post type opts in; the work shifts to shaping and securing it rather than building it from nothing.

2. Custom Endpoints

Custom data — like Week 21's inquiries table — needs a hand-registered route. register_rest_route() is WordPress's equivalent of Laravel's Route::get() from Week 9:

includes/rest-api.php
<?php

if (!defined('ABSPATH')) exit;

function pm_register_rest_routes() {
    register_rest_route('portfolio-manager/v1', '/projects/(?P<id>\d+)/inquiries', [
        'methods' => 'GET',
        'callback' => 'pm_get_inquiries_rest',
        'permission_callback' => 'pm_check_manage_options', // who's allowed to call this
        'args' => [
            'id' => ['validate_callback' => fn($value) => is_numeric($value)],
        ],
    ]);
}
add_action('rest_api_init', 'pm_register_rest_routes');

function pm_get_inquiries_rest(\WP_REST_Request $request): \WP_REST_Response {
    $projectId = (int) $request['id'];
    $inquiries = pm_get_inquiries_for_project($projectId); // from Week 21

    return new \WP_REST_Response($inquiries, 200);
}

function pm_check_manage_options(): bool {
    return current_user_can('manage_options');
}

portfolio-manager/v1 as the namespace is deliberate — the same versioning discipline from Week 15's Laravel API, applied here so a future breaking change can ship as v2 without touching existing integrations.

3. REST API Authentication

permission_callback from above is doing real work — without it (or with one that always returns true), the endpoint is open to anyone, including anonymous requests. current_user_can('manage_options') checks the currently logged-in WordPress user's capability, the same concept as Week 21's admin menu check.

For requests not coming from a logged-in browser session — a separate frontend app, a script — WordPress's REST API supports Application Passwords out of the box, no extra plugin required as of WordPress 5.6+:

terminal — generating & using one
wp user application-password create admin "headless-frontend"
# Prints a one-time password like: abcd 1234 efgh 5678

curl -u "admin:abcd1234efgh5678" \
  -X POST http://localhost:8000/wp-json/wp/v2/posts \
  -H "Content-Type: application/json" \
  -d '{"title": "Created via REST API", "status": "publish"}'
Never expose the built-in write endpoints without thinking through permissions

POST /wp-json/wp/v2/posts is live by default the moment a plugin doesn't lock it down — Application Passwords authenticate the request, but permission_callback logic (built into WordPress core for its own endpoints) still governs what an authenticated user is actually allowed to do. Always confirm what an endpoint permits before treating "it requires auth" as "it's safe."

4. Headless WordPress

Headless WordPress means using WordPress purely as a content backend — the admin, the database, the REST API — while an entirely separate application (a React/Next.js app, a static site generator) handles every bit of the actual frontend rendering:

a minimal headless fetch, from any JS frontend
async function getProjects() {
  const res = await fetch('http://localhost:8000/wp-json/wp/v2/project?_embed');
  const projects = await res.json();

  return projects.map(p => ({
    id: p.id,
    title: p.title.rendered,
    excerpt: p.excerpt.rendered,
    image: p._embedded?.['wp:featuredmedia']?.[0]?.source_url,
  }));
}

?_embed tells the REST API to include related resources (like the featured image) inline in the same response, avoiding a separate round-trip — the REST equivalent of Week 12's eager loading, solving the exact same N+1-shaped problem for API consumers.

In this architecture, everything built in Weeks 18–19 — the template hierarchy, The Loop, template parts — is entirely bypassed. WordPress never renders HTML at all; it's purely a data source and admin interface, with the REST API as the only boundary between the two halves.

5. When Headless Makes Sense

A genuine trade-off, not a strict upgrade — headless makes sense when:

  • The frontend needs a framework WordPress themes can't provide (a native mobile app, a highly interactive SPA)
  • Non-technical editors still need WordPress's mature admin UI for content management
  • Frontend and backend teams want to work and deploy independently

And a traditional (non-headless) theme is usually the better call when:

  • The site is primarily content — blog, brochure site, small business site — with no need for a separate app-like frontend
  • SEO and fast first-paint matter and the team doesn't want to invest in server-side rendering for the separate frontend
  • The team is smaller and maintaining two separate codebases (WordPress + frontend app) isn't worth the added complexity

Weeks 17–21 built the traditional path; this week added the option, not a replacement — most WordPress sites in production are still theme-rendered, and knowing both lets you pick deliberately rather than defaulting to whichever is trendier.

6. Hands-on Exercise

Hands-on

Expose & consume the Portfolio Manager as an API

Secure a custom endpoint properly, then build a minimal headless view against it.

Requirements:

  1. Confirm /wp-json/wp/v2/project already returns your projects (from show_in_rest => true in Week 19), including _embed-ded featured images.
  2. Register a custom endpoint /portfolio-manager/v1/projects/{id}/inquiries returning inquiries for a project, gated by permission_callback checking manage_options.
  3. Confirm the endpoint returns 401/403 for an unauthenticated request, and succeeds with an Application Password.
  4. Build a minimal standalone HTML+JS page (outside WordPress entirely) that fetches /wp-json/wp/v2/project?_embed and renders a project grid with images — a small proof-of-concept headless frontend.
Hint

Your standalone HTML page will hit a CORS error fetching from a different origin unless the WordPress site sends the right headers — the rest_api_init hook is also where you'd add an Access-Control-Allow-Origin header for a real headless setup, worth researching as you hit the error.

7. Knowledge Check

Four quick questions. Expand each to check your answer.

Q1

Why does Week 19's project CPT already have a working REST endpoint, with no code written for it specifically?

Because it was registered with show_in_rest => true back in Week 19. That single flag tells WordPress core to automatically generate a full set of REST endpoints for the post type — list, single, create, update, delete — using the same built-in machinery every ordinary post already exposes, with no custom route registration required.

Q2

What does omitting (or misusing) permission_callback on a custom REST route actually expose?

Without a real permission check, the route is reachable by anyone — including anonymous, unauthenticated requests — regardless of what the data actually is. This is exactly the authorization concern from Week 13's Policies and Week 21's manage_options check, applied to an API route instead of a web page: authentication (who is making the request) and authorization (what they're allowed to see or do) are still two separate questions that both need answering.

Q3

What problem does ?_embed solve, and what earlier lesson does it directly parallel?

Without it, a frontend that needs each post's featured image would have to make a separate request per post to fetch the media details — the REST API's own version of the N+1 problem from Week 12. ?_embed tells the API to include related resources (featured media, author, terms) inline in the same response, the same "fetch related data in one round trip" idea as Eloquent's with().

Q4

Is "headless WordPress" strictly better than a traditional theme, and why or why not?

No — it's a genuine trade-off, not a strict upgrade. Headless makes sense when the frontend needs capabilities a WordPress theme can't easily provide (a native app, a highly interactive SPA) while keeping WordPress's mature admin for content editors. For a primarily content-driven site, maintaining two separate codebases and losing WordPress's built-in server-side rendering usually isn't worth the added complexity — most production WordPress sites are still theme-rendered for exactly this reason.