Skip to content
~sami-ullah

Concept project · runs in your browserTypeScript · React.js · Next.js

Headless WordPress and Next.js Blog Demo

Edit a post in the mini wp-admin on the left and press Publish. The event log shows WordPress firing transition_post_status, the webhook reaching the Next.js revalidate route, cache tags being purged and the page regenerating. Preview a draft through Draft Mode, query the mock REST API directly, or break the shared secret to see a 401.

Read the case study →

Interactive demo

samiullah.tech/lab/headless-wordpress-nextjs
clock +0hCached fetches expire after 1 hour even if a webhook is missed.
Acme Journal (cms.acme-journal.test)Howdy, Dana Editor

Posts

All posts
TitleCategoryDate
— Draft
EngineeringLast modified
2026-11-02
EngineeringPublished
2026-10-28
DesignPublished
2026-10-14
CompanyPublished
2026-09-30
acme-journal.test/blog

Loading…

No requests yet.

event log (WordPress → Next.js)

  1. $ load a page or publish a post…

## How it works

Every page fetch is tagged: the list uses wp:posts, a post page uses wp:post:<slug>, a category archive adds wp:category:<id>. Pages are served from the cache until one of their tags is purged, so most visits never reach WordPress. The Data cache tab shows each stored fetch and its tags.

When a post moves into or out of publish, the plugin's transition_post_status hook posts the slug and categories to /api/revalidate with a shared secret. The route checks the secret, then calls revalidateTag for the post, the list and its categories. The next visitor gets a fresh render.

Previews use a signed link from preview_post_link. The preview route turns on Draft Mode, which bypasses the cache and fetches with credentials, so drafts are visible to the editor and never stored in the public cache. A one-hour time-based revalidation is the safety net if a webhook is ever lost.

## View source

The code running in the window above, exactly as deployed.

app/lab/headless-wordpress-nextjs/wp-api.ts329 lines
/**
 * A mock of the WordPress REST API (`/wp-json/wp/v2/...`), kept free of React so it reads like the server it stands in for.
 * Response shapes follow core: `title.rendered`, `_embedded["wp:featuredmedia"]`, `X-WP-Total` headers, `rest_*` error codes,
 * and an `acf` object as exposed by Advanced Custom Fields when "Show in REST API" is enabled on the field group.
 */

export type PostStatus = "publish" | "draft" | "future";

export type WpPost = {
  id: number;
  slug: string;
  status: PostStatus;
  date: string; // ISO, site timezone
  modified: string;
  title: string;
  excerpt: string;
  content: string; // HTML as stored by the block editor
  author: number;
  categories: number[];
  featured_media: number;
  acf: { subtitle: string; reading_time: number; show_toc: boolean };
};

export type WpCategory = { id: number; name: string; slug: string; description: string };
export type WpMedia = { id: number; alt_text: string; source_url: string; width: number; height: number; hue: number };
export type WpUser = { id: number; name: string; slug: string };

export type WpDb = {
  posts: WpPost[];
  categories: WpCategory[];
  media: WpMedia[];
  users: WpUser[];
  nextId: number;
};

export type RestResponse = {
  status: number;
  headers: Record<string, string>;
  body: unknown;
};

export const SITE = "https://cms.acme-journal.test";

export function seedDb(): WpDb {
  const iso = (d: string) => `${d}T09:30:00`;
  return {
    nextId: 140,
    users: [
      { id: 2, name: "Dana Editor", slug: "dana" },
      { id: 3, name: "Omar Writer", slug: "omar" },
    ],
    categories: [
      { id: 4, name: "Engineering", slug: "engineering", description: "How the product is built." },
      { id: 5, name: "Design", slug: "design", description: "Interfaces, type and colour." },
      { id: 6, name: "Company", slug: "company", description: "News from the team." },
    ],
    media: [
      { id: 201, alt_text: "Laptop on a desk with a code editor open", source_url: `${SITE}/wp-content/uploads/2026/10/editor.webp`, width: 1600, height: 900, hue: 150 },
      { id: 202, alt_text: "Colour swatches pinned to a board", source_url: `${SITE}/wp-content/uploads/2026/10/swatches.webp`, width: 1600, height: 900, hue: 320 },
      { id: 203, alt_text: "Team standing around a whiteboard", source_url: `${SITE}/wp-content/uploads/2026/09/whiteboard.webp`, width: 1600, height: 900, hue: 210 },
    ],
    posts: [
      {
        id: 131,
        slug: "why-we-moved-to-headless",
        status: "publish",
        date: iso("2026-10-28"),
        modified: iso("2026-10-28"),
        title: "Why we moved our blog to headless WordPress",
        excerpt: "Editors kept the admin they know. Readers got pages served from a CDN.",
        content:
          "<p>Our editors liked WordPress. Our readers did not like waiting for it.</p><h2>The split</h2><p>WordPress now only stores and edits content. A Next.js app reads it through the REST API and serves static pages.</p><ul><li>No theme to maintain</li><li>Pages cached at the edge</li><li>Previews still work</li></ul>",
        author: 2,
        categories: [4],
        featured_media: 201,
        acf: { subtitle: "Same editor, faster pages", reading_time: 4, show_toc: true },
      },
      {
        id: 127,
        slug: "a-colour-system-in-three-tokens",
        status: "publish",
        date: iso("2026-10-14"),
        modified: iso("2026-10-15"),
        title: "A colour system in three tokens",
        excerpt: "Background, foreground, accent. Everything else is derived.",
        content: "<p>We cut our palette from 40 colours to three base tokens.</p><h2>Deriving the rest</h2><p>Muted text and borders are mixes of the base tokens, so a new theme is three values.</p>",
        author: 3,
        categories: [5],
        featured_media: 202,
        acf: { subtitle: "Fewer decisions, fewer bugs", reading_time: 3, show_toc: false },
      },
      {
        id: 119,
        slug: "our-first-offsite",
        status: "publish",
        date: iso("2026-09-30"),
        modified: iso("2026-09-30"),
        title: "Notes from our first offsite",
        excerpt: "Two days, one whiteboard and a shorter roadmap.",
        content: "<p>We spent two days deciding what not to build.</p>",
        author: 2,
        categories: [6],
        featured_media: 203,
        acf: { subtitle: "What we decided not to build", reading_time: 2, show_toc: false },
      },
      {
        id: 136,
        slug: "image-pipeline-draft",
        status: "draft",
        date: iso("2026-11-02"),
        modified: iso("2026-11-02"),
        title: "Draft: our image pipeline",
        excerpt: "WebP, AVIF and the srcset we actually ship.",
        content: "<p>This post is still a draft. It should only be visible in preview mode.</p>",
        author: 3,
        categories: [4],
        featured_media: 0,
        acf: { subtitle: "Work in progress", reading_time: 5, show_toc: true },
      },
    ],
  };
}

/* ------------------------------------------------------------------ */
/* Serialisation: what /wp/v2/posts actually returns                   */
/* ------------------------------------------------------------------ */

const link = (p: WpPost) => `https://acme-journal.test/blog/${p.slug}`;

function serializePost(db: WpDb, p: WpPost, embed: boolean, context: "view" | "edit") {
  const base: Record<string, unknown> = {
    id: p.id,
    date: p.date,
    modified: p.modified,
    slug: p.slug,
    status: p.status,
    type: "post",
    link: link(p),
    title: { rendered: escapeText(p.title), ...(context === "edit" ? { raw: p.title } : {}) },
    content: { rendered: p.content, protected: false },
    excerpt: { rendered: `<p>${escapeText(p.excerpt)}</p>\n`, protected: false },
    author: p.author,
    featured_media: p.featured_media,
    categories: p.categories,
    acf: p.acf,
    _links: {
      self: [{ href: `${SITE}/wp-json/wp/v2/posts/${p.id}` }],
      author: [{ embeddable: true, href: `${SITE}/wp-json/wp/v2/users/${p.author}` }],
      ...(p.featured_media ? { "wp:featuredmedia": [{ embeddable: true, href: `${SITE}/wp-json/wp/v2/media/${p.featured_media}` }] } : {}),
    },
  };
  if (embed) {
    const media = db.media.find((m) => m.id === p.featured_media);
    base._embedded = {
      author: [db.users.find((u) => u.id === p.author) ?? null],
      ...(media ? { "wp:featuredmedia": [serializeMedia(media)] } : {}),
      "wp:term": [db.categories.filter((c) => p.categories.includes(c.id)).map((c) => ({ ...c, taxonomy: "category" }))],
    };
  }
  return base;
}

function serializeMedia(m: WpMedia) {
  return {
    id: m.id,
    alt_text: m.alt_text,
    media_type: "image",
    mime_type: "image/webp",
    source_url: m.source_url,
    media_details: {
      width: m.width,
      height: m.height,
      sizes: {
        medium_large: { width: 768, height: 432, source_url: m.source_url.replace(".webp", "-768x432.webp") },
        full: { width: m.width, height: m.height, source_url: m.source_url },
      },
    },
  };
}

/** WordPress runs titles through wptexturize/esc_html; enough here to escape markup characters. */
export function escapeText(s: string) {
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}

const err = (status: number, code: string, message: string, extra: Record<string, unknown> = {}): RestResponse => ({
  status,
  headers: { "Content-Type": "application/json; charset=UTF-8" },
  body: { code, message, data: { status, ...extra } },
});

/* ------------------------------------------------------------------ */
/* Router                                                              */
/* ------------------------------------------------------------------ */

export type RestRequest = {
  method: "GET";
  path: string; // e.g. /wp/v2/posts?per_page=2&_embed
  /** Application password / preview token. Drafts and context=edit require it. */
  auth?: boolean;
};

/**
 * Handle a GET against the mock API. Mirrors the core controllers:
 * - WP_REST_Posts_Controller::get_items: per_page, page, categories, search, slug, status, orderby=date, _embed
 * - WP_REST_Posts_Controller::get_item: 401 rest_forbidden for drafts without auth
 * - WP_REST_Terms_Controller and WP_REST_Attachments_Controller for categories and media
 */
export function handle(db: WpDb, req: RestRequest): RestResponse {
  const url = new URL(req.path, `${SITE}/wp-json`);
  const route = url.pathname.replace(/^\/wp-json/, "");
  const q = url.searchParams;
  const embed = q.has("_embed");
  const json = (body: unknown, extra: Record<string, string> = {}): RestResponse => ({
    status: 200,
    headers: { "Content-Type": "application/json; charset=UTF-8", ...extra },
    body,
  });

  let m: RegExpExecArray | null;

  if (route === "/wp/v2/posts") {
    const perPage = Number(q.get("per_page") ?? 10);
    const page = Number(q.get("page") ?? 1);
    if (!Number.isInteger(perPage) || perPage < 1 || perPage > 100) {
      return err(400, "rest_invalid_param", "Invalid parameter(s): per_page", { params: { per_page: "per_page must be between 1 (inclusive) and 100 (inclusive)" } });
    }
    const status = (q.get("status") ?? "publish").split(",");
    if (status.some((s) => s !== "publish") && !req.auth) {
      return err(400, "rest_invalid_param", "Invalid parameter(s): status", { params: { status: "Status is forbidden." } });
    }
    let list = db.posts.filter((p) => status.includes(p.status));
    const cats = q.get("categories");
    if (cats) {
      const ids = cats.split(",").map(Number);
      list = list.filter((p) => p.categories.some((c) => ids.includes(c)));
    }
    const slug = q.get("slug");
    if (slug) list = list.filter((p) => p.slug === slug);
    const search = q.get("search")?.toLowerCase().trim();
    if (search) list = list.filter((p) => (p.title + " " + p.excerpt + " " + p.content).toLowerCase().includes(search));
    list = [...list].sort((a, b) => b.date.localeCompare(a.date));

    const total = list.length;
    const totalPages = Math.max(1, Math.ceil(total / perPage));
    if (page > totalPages && total > 0) {
      return err(400, "rest_post_invalid_page_number", "The page number requested is larger than the number of pages available.");
    }
    const slice = list.slice((page - 1) * perPage, page * perPage);
    return json(
      slice.map((p) => serializePost(db, p, embed, req.auth ? "edit" : "view")),
      { "X-WP-Total": String(total), "X-WP-TotalPages": String(totalPages) },
    );
  }

  if ((m = /^\/wp\/v2\/posts\/(\d+)$/.exec(route))) {
    const p = db.posts.find((x) => x.id === Number(m![1]));
    if (!p) return err(404, "rest_post_invalid_id", "Invalid post ID.");
    if (p.status !== "publish" && !req.auth) return err(401, "rest_forbidden", "Sorry, you are not allowed to do that.");
    return json(serializePost(db, p, embed, req.auth ? "edit" : "view"));
  }

  if (route === "/wp/v2/categories") {
    const counts = (id: number) => db.posts.filter((p) => p.status === "publish" && p.categories.includes(id)).length;
    return json(
      db.categories.map((c) => ({ ...c, count: counts(c.id), taxonomy: "category", link: `https://acme-journal.test/category/${c.slug}` })),
      { "X-WP-Total": String(db.categories.length), "X-WP-TotalPages": "1" },
    );
  }

  if ((m = /^\/wp\/v2\/media\/(\d+)$/.exec(route))) {
    const media = db.media.find((x) => x.id === Number(m![1]));
    if (!media) return err(404, "rest_post_invalid_id", "Invalid post ID.");
    return json(serializeMedia(media));
  }

  return err(404, "rest_no_route", "No route was found matching the URL and request method.");
}

/* ------------------------------------------------------------------ */
/* Editor actions (what wp-admin does when you press the buttons)      */
/* ------------------------------------------------------------------ */

export type PostDraft = Pick<WpPost, "title" | "excerpt" | "content" | "categories" | "featured_media" | "acf">;

export function slugify(title: string) {
  return (
    title
      .toLowerCase()
      .normalize("NFKD")
      .replace(/[^\w\s-]/g, "")
      .trim()
      .replace(/[\s_-]+/g, "-")
      .slice(0, 60) || "untitled"
  );
}

/** wp_insert_post / wp_update_post. Returns the new db and the transition (old_status -> new_status). */
export function savePost(
  db: WpDb,
  id: number | null,
  draft: PostDraft,
  status: PostStatus,
  nowIso: string,
): { db: WpDb; post: WpPost; oldStatus: PostStatus | "new" } {
  const existing = id ? db.posts.find((p) => p.id === id) : undefined;
  let slug = existing?.slug ?? slugify(draft.title);
  // wp_unique_post_slug: append -2, -3... on clashes
  let n = 2;
  const base = slug;
  while (db.posts.some((p) => p.slug === slug && p.id !== existing?.id)) slug = `${base}-${n++}`;

  const post: WpPost = {
    id: existing?.id ?? db.nextId,
    slug,
    status,
    date: existing && existing.status === "publish" ? existing.date : nowIso,
    modified: nowIso,
    author: existing?.author ?? 2,
    ...draft,
  };
  const posts = existing ? db.posts.map((p) => (p.id === post.id ? post : p)) : [post, ...db.posts];
  return { db: { ...db, posts, nextId: existing ? db.nextId : db.nextId + 1 }, post, oldStatus: existing?.status ?? "new" };
}

export function trashPost(db: WpDb, id: number): WpDb {
  return { ...db, posts: db.posts.filter((p) => p.id !== id) };
}
app/lab/headless-wordpress-nextjs/next-site.ts197 lines
/**
 * The Next.js side of the headless setup, written as plain functions so the moving parts are visible:
 * - a tagged data cache (what `fetch(url, { next: { tags } })` does in the App Router),
 * - the `/api/revalidate` route handler that WordPress calls on publish,
 * - the `/api/preview` route that turns on Draft Mode for signed preview links.
 * In a real app these are `lib/wp.ts`, `app/api/revalidate/route.ts` and `app/api/preview/route.ts`.
 */

import { handle, type RestResponse, type WpDb } from "./wp-api";

/* ------------------------------------------------------------------ */
/* Tagged data cache                                                   */
/* ------------------------------------------------------------------ */

export type CacheEntry = { url: string; tags: string[]; storedAt: number; response: RestResponse };
export type DataCache = Record<string, CacheEntry>;
export type CacheResult = { response: RestResponse; cache: "HIT" | "MISS" | "SKIP"; next: DataCache };

/** Fallback time-based revalidation, in case a webhook is ever missed (export const revalidate = 3600). */
export const REVALIDATE_SECONDS = 3600;

/**
 * wpFetch(path, tags): in Next.js this is
 *   fetch(`${WP}/wp-json${path}`, { next: { tags, revalidate: 3600 } })
 * Draft Mode skips the cache entirely and sends the application password, like `cache: "no-store"` + Authorization.
 */
export function wpFetch(db: WpDb, cache: DataCache, path: string, tags: string[], opts: { draft: boolean; now: number }): CacheResult {
  if (opts.draft) {
    return { response: handle(db, { method: "GET", path, auth: true }), cache: "SKIP", next: cache };
  }
  const hit = cache[path];
  if (hit && opts.now - hit.storedAt < REVALIDATE_SECONDS * 1000) {
    return { response: hit.response, cache: "HIT", next: cache };
  }
  const response = handle(db, { method: "GET", path });
  // Next.js only caches successful responses.
  const next = response.status === 200 ? { ...cache, [path]: { url: path, tags, storedAt: opts.now, response } } : cache;
  return { response, cache: "MISS", next };
}

/** revalidateTag(tag): every cached fetch carrying the tag is purged; the next request refetches from WordPress. */
export function revalidateTag(cache: DataCache, tag: string): { next: DataCache; purged: string[] } {
  const purged = Object.keys(cache).filter((k) => cache[k]!.tags.includes(tag));
  const next = { ...cache };
  for (const k of purged) delete next[k];
  return { next, purged };
}

/** Cache tags, one place, so the page fetches and the webhook agree on names. */
export const tags = {
  posts: "wp:posts",
  post: (slug: string) => `wp:post:${slug}`,
  category: (id: number) => `wp:category:${id}`,
  categories: "wp:categories",
};

/* ------------------------------------------------------------------ */
/* Route handlers                                                      */
/* ------------------------------------------------------------------ */

export type WebhookPayload = {
  post_id: number;
  slug: string;
  old_status: string;
  new_status: string;
  categories: number[];
};

/** Shared secret: in production REVALIDATE_SECRET env var in Next.js and a constant in wp-config.php. */
export const REVALIDATE_SECRET = "s3cr3t-d3m0-only";

/**
 * POST /api/revalidate
 *   export async function POST(req: Request) {
 *     if (req.headers.get("x-revalidate-secret") !== process.env.REVALIDATE_SECRET) return 401;
 *     const body = schema.parse(await req.json());
 *     for (const t of tagsFor(body)) revalidateTag(t);
 *     return Response.json({ revalidated: true, tags });
 *   }
 */
export function revalidateRoute(
  cache: DataCache,
  headers: Record<string, string>,
  payload: unknown,
): { status: number; body: unknown; next: DataCache; purged: string[] } {
  if (headers["x-revalidate-secret"] !== REVALIDATE_SECRET) {
    return { status: 401, body: { revalidated: false, message: "Invalid secret" }, next: cache, purged: [] };
  }
  const p = payload as Partial<WebhookPayload>;
  if (typeof p?.post_id !== "number" || typeof p.slug !== "string" || !Array.isArray(p.categories)) {
    return { status: 400, body: { revalidated: false, message: "Body must include post_id, slug and categories" }, next: cache, purged: [] };
  }
  const list = [tags.posts, tags.post(p.slug), tags.categories, ...p.categories.map(tags.category)];
  let next = cache;
  const purged: string[] = [];
  for (const t of list) {
    const r = revalidateTag(next, t);
    next = r.next;
    purged.push(...r.purged);
  }
  return { status: 200, body: { revalidated: true, tags: list, now: "<server time>" }, next, purged: [...new Set(purged)] };
}

/**
 * GET /api/preview?secret=…&id=…
 * Validates the preview secret, checks the post exists with an authenticated request, enables Draft Mode
 * (sets the __prerender_bypass cookie) and redirects to the post URL.
 */
export function previewRoute(db: WpDb, secret: string, id: number): { status: number; location?: string; body?: unknown } {
  if (secret !== REVALIDATE_SECRET) return { status: 401, body: { message: "Invalid preview token" } };
  const r = handle(db, { method: "GET", path: `/wp/v2/posts/${id}`, auth: true });
  if (r.status !== 200) return { status: 404, body: { message: "Post not found" } };
  const slug = (r.body as { slug: string }).slug;
  return { status: 307, location: `/blog/${slug}` };
}

/* ------------------------------------------------------------------ */
/* Typed page data, as the React Server Components would consume it   */
/* ------------------------------------------------------------------ */

export type CardPost = {
  id: number;
  slug: string;
  title: string;
  excerpt: string;
  date: string;
  status: string;
  subtitle: string;
  readingTime: number;
  author: string;
  category: string;
  image: { alt: string; hue: number } | null;
  content: string;
  showToc: boolean;
};

type RawPost = {
  id: number;
  slug: string;
  status: string;
  date: string;
  title: { rendered: string };
  excerpt: { rendered: string };
  content: { rendered: string };
  acf: { subtitle: string; reading_time: number; show_toc: boolean };
  _embedded?: {
    author?: ({ name: string } | null)[];
    "wp:featuredmedia"?: { alt_text: string; source_url: string }[];
    "wp:term"?: { name: string }[][];
  };
};

/** Map the REST shape to what the components need, with the featured image hue looked up from the media id. */
export function toCard(raw: RawPost, db: WpDb): CardPost {
  const media = raw._embedded?.["wp:featuredmedia"]?.[0];
  const hue = media ? db.media.find((m) => m.source_url === media.source_url)?.hue ?? 200 : 0;
  return {
    id: raw.id,
    slug: raw.slug,
    status: raw.status,
    title: decode(raw.title.rendered),
    excerpt: decode(raw.excerpt.rendered.replace(/<[^>]+>/g, "").trim()),
    date: raw.date.slice(0, 10),
    subtitle: raw.acf.subtitle,
    readingTime: raw.acf.reading_time,
    showToc: raw.acf.show_toc,
    author: raw._embedded?.author?.[0]?.name ?? "Unknown",
    category: raw._embedded?.["wp:term"]?.[0]?.[0]?.name ?? "Uncategorised",
    image: media ? { alt: media.alt_text, hue } : null,
    content: raw.content.rendered,
  };
}

function decode(s: string) {
  return s.replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&amp;/g, "&");
}

/**
 * Allow-list sanitiser for post HTML before rendering. In production this is `sanitize-html` on the server;
 * here it keeps a small set of tags and drops every attribute, so stored markup cannot inject scripts or handlers.
 */
const ALLOWED = new Set(["p", "h2", "h3", "ul", "ol", "li", "strong", "em", "code", "blockquote", "a"]);
export function sanitizeHtml(html: string): string {
  return html
    .replace(/<(script|style|iframe)[\s\S]*?<\/\1>/gi, "")
    .replace(/<\/?([a-z0-9]+)(\s[^>]*)?>/gi, (tag, name: string) => {
      const n = name.toLowerCase();
      if (!ALLOWED.has(n)) return "";
      return tag.startsWith("</") ? `</${n}>` : `<${n}>`;
    });
}

/** Headings for the ACF "show table of contents" option. */
export function headings(html: string): string[] {
  return [...html.matchAll(/<h2>(.*?)<\/h2>/g)].map((m) => m[1]!.replace(/<[^>]+>/g, ""));
}
app/lab/headless-wordpress-nextjs/php/acme-headless.php115 lines
<?php
/**
 * Plugin Name: Acme Headless Bridge
 * Description: Tells the Next.js front end when content changes, signs preview links and exposes ACF fields in REST.
 * Version:     1.0.0
 * Requires PHP: 8.1
 *
 * Production design for the headless WordPress + Next.js demo. wp-config.php defines:
 *   define( 'ACME_FRONTEND_URL', 'https://acme-journal.test' );
 *   define( 'ACME_REVALIDATE_SECRET', '...' ); // same value as REVALIDATE_SECRET in Next.js
 */

defined( 'ABSPATH' ) || exit;

/**
 * 1. Revalidate on publish, update, unpublish and trash.
 * transition_post_status fires for every status change, so one hook covers all four.
 */
add_action( 'transition_post_status', 'acme_headless_notify', 10, 3 );

function acme_headless_notify( string $new_status, string $old_status, WP_Post $post ): void {
	if ( 'post' !== $post->post_type || wp_is_post_revision( $post ) || wp_is_post_autosave( $post ) ) {
		return;
	}
	// Only changes that affect public pages: something was, or now is, published.
	if ( 'publish' !== $new_status && 'publish' !== $old_status ) {
		return;
	}

	$payload = array(
		'post_id'    => $post->ID,
		'slug'       => $post->post_name,
		'old_status' => $old_status,
		'new_status' => $new_status,
		'categories' => wp_get_post_categories( $post->ID ),
	);

	// Non-blocking: the editor should not wait for Vercel to answer.
	wp_remote_post(
		trailingslashit( ACME_FRONTEND_URL ) . 'api/revalidate',
		array(
			'headers'  => array(
				'Content-Type'        => 'application/json',
				'x-revalidate-secret' => ACME_REVALIDATE_SECRET,
			),
			'body'     => wp_json_encode( $payload ),
			'timeout'  => 3,
			'blocking' => false,
		)
	);
}

/**
 * 2. Preview: the "Preview" button opens the Next.js preview route instead of the (disabled) theme.
 */
add_filter( 'preview_post_link', 'acme_headless_preview_link', 10, 2 );

function acme_headless_preview_link( string $link, WP_Post $post ): string {
	return add_query_arg(
		array(
			'secret' => rawurlencode( ACME_REVALIDATE_SECRET ),
			'id'     => $post->ID,
		),
		trailingslashit( ACME_FRONTEND_URL ) . 'api/preview'
	);
}

/**
 * 3. ACF field group registered in code (version controlled), with show_in_rest so the
 *    fields appear under "acf" in /wp-json/wp/v2/posts.
 */
add_action( 'acf/include_fields', 'acme_headless_fields' );

function acme_headless_fields(): void {
	if ( ! function_exists( 'acf_add_local_field_group' ) ) {
		return;
	}
	acf_add_local_field_group(
		array(
			'key'          => 'group_acme_post_meta',
			'title'        => 'Post details',
			'show_in_rest' => 1,
			'location'     => array( array( array( 'param' => 'post_type', 'operator' => '==', 'value' => 'post' ) ) ),
			'fields'       => array(
				array( 'key' => 'field_acme_subtitle', 'name' => 'subtitle', 'label' => 'Subtitle', 'type' => 'text', 'maxlength' => 80 ),
				array( 'key' => 'field_acme_reading', 'name' => 'reading_time', 'label' => 'Reading time (min)', 'type' => 'number', 'min' => 1, 'max' => 60 ),
				array( 'key' => 'field_acme_toc', 'name' => 'show_toc', 'label' => 'Show table of contents', 'type' => 'true_false', 'ui' => 1 ),
			),
		)
	);
}

/**
 * 4. The WordPress front end is not public: redirect any theme request to the same path on Next.js.
 *    wp-admin, REST, cron and previews keep working.
 */
add_action( 'template_redirect', 'acme_headless_redirect_front' );

function acme_headless_redirect_front(): void {
	if ( is_admin() || wp_doing_cron() || ( defined( 'REST_REQUEST' ) && REST_REQUEST ) || is_preview() ) {
		return;
	}
	$path = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '/';
	wp_safe_redirect( untrailingslashit( ACME_FRONTEND_URL ) . $path, 301 );
	exit;
}

add_filter(
	'allowed_redirect_hosts',
	static function ( array $hosts ): array {
		$hosts[] = (string) wp_parse_url( ACME_FRONTEND_URL, PHP_URL_HOST );
		return $hosts;
	}
);
app/lab/headless-wordpress-nextjs/Demo.tsx756 lines
"use client";

import { useEffect, useState } from "react";
import { LogPanel, pushLine, type LogLine, type LogTone } from "@/components/lab/wp/LogPanel";
import { usePersistentState } from "@/components/lab/wp/usePersistentState";
import { WpAdminFrame, WpNotice, wpBtn, wpBtnPrimary } from "@/components/lab/wp/WpAdminFrame";
import {
  REVALIDATE_SECRET,
  headings,
  previewRoute,
  revalidateRoute,
  sanitizeHtml,
  tags,
  toCard,
  wpFetch,
  type CardPost,
  type DataCache,
} from "./next-site";
import { handle, savePost, seedDb, trashPost, type PostDraft, type PostStatus, type RestResponse, type WpDb } from "./wp-api";

const KEY = "lab:headless-wordpress-nextjs:v1";

type Persisted = { db: WpDb; cache: DataCache; secret: string; webhookOn: boolean };
const seed = (): Persisted => ({ db: seedDb(), cache: {}, secret: REVALIDATE_SECRET, webhookOn: true });

type View =
  | { kind: "list"; posts: CardPost[]; categories: { id: number; name: string; slug: string; count: number }[]; activeCat: string | null; search: string }
  | { kind: "post"; post: CardPost }
  | { kind: "404" };

type Exchange = { label: string; path: string; response: RestResponse; cache?: string };

const EMPTY_DRAFT: PostDraft = {
  title: "",
  excerpt: "",
  content: "<p></p>",
  categories: [4],
  featured_media: 0,
  acf: { subtitle: "", reading_time: 3, show_toc: false },
};

const CONSOLE_PRESETS = [
  "/wp/v2/posts?per_page=2&_embed",
  "/wp/v2/posts?search=colour",
  "/wp/v2/posts?status=draft",
  "/wp/v2/posts/136",
  "/wp/v2/categories",
  "/wp/v2/media/201",
  "/wp/v2/posts?per_page=500",
  "/wp/v2/pages",
];

export default function Demo() {
  const [state, setState, resetState, loaded] = usePersistentState<Persisted>(KEY, seed);
  const { db, cache } = state;

  // Front end
  const [route, setRoute] = useState("/blog");
  const [view, setView] = useState<View | null>(null);
  const [draftMode, setDraftMode] = useState(false);
  const [renderInfo, setRenderInfo] = useState<{ cache: string[]; at: string } | null>(null);
  const [searchBox, setSearchBox] = useState("");

  // Admin
  const [screen, setScreen] = useState<"posts" | "edit" | "settings">("posts");
  const [editingId, setEditingId] = useState<number | null>(null);
  const [form, setForm] = useState<PostDraft>(EMPTY_DRAFT);
  const [notice, setNotice] = useState<{ type: "success" | "error" | "info"; text: string } | null>(null);
  const [formError, setFormError] = useState<string | null>(null);

  // Inspector
  const [tab, setTab] = useState<"response" | "console" | "cache">("response");
  const [exchanges, setExchanges] = useState<Exchange[]>([]);
  const [consolePath, setConsolePath] = useState(CONSOLE_PRESETS[0]!);
  const [consoleAuth, setConsoleAuth] = useState(false);
  const [consoleResult, setConsoleResult] = useState<Exchange | null>(null);
  const [log, setLog] = useState<LogLine[]>([]);
  const [clockHours, setClockHours] = useState(0);

  const push = (text: string, tone: LogTone) => setLog((l) => pushLine(l, text, tone));
  const now = () => Date.now() + clockHours * 3_600_000;

  /** Render a front-end route the way the App Router would: run the page's fetches through the data cache. */
  function render(path: string, opts: { db?: WpDb; cache?: DataCache; draft?: boolean } = {}) {
    const d = opts.db ?? db;
    let c = opts.cache ?? cache;
    const draft = opts.draft ?? draftMode;
    const hits: string[] = [];
    const ex: Exchange[] = [];
    const run = (p: string, t: string[]) => {
      const r = wpFetch(d, c, p, t, { draft, now: now() });
      c = r.next;
      hits.push(`${r.cache} ${p}`);
      ex.push({ label: `fetch (${r.cache})`, path: p, response: r.response, cache: r.cache });
      push(`  fetch ${p}  [${r.cache}${r.cache === "MISS" ? " → WordPress " + r.response.status : ""}]`, r.cache === "HIT" ? "dim" : "hook");
      return r.response;
    };

    push(`GET ${path}${draft ? "  (draft mode cookie)" : ""}`, "info");
    const url = new URL(path, "https://acme-journal.test");
    const parts = url.pathname.split("/").filter(Boolean);
    let next: View;

    if (parts.length === 1 || parts[1] === "category") {
      const catSlug = parts[1] === "category" ? (parts[2] ?? null) : null;
      const catsRes = run("/wp/v2/categories", [tags.categories]);
      const cats = (catsRes.body as { id: number; name: string; slug: string; count: number }[]) ?? [];
      const cat = catSlug ? cats.find((x) => x.slug === catSlug) : undefined;
      const search = url.searchParams.get("q") ?? "";
      const qs = new URLSearchParams({ per_page: "10" });
      if (cat) qs.set("categories", String(cat.id));
      if (search) qs.set("search", search);
      if (draft) qs.set("status", "publish,draft");
      const res = run(`/wp/v2/posts?${qs.toString()}&_embed`, cat ? [tags.posts, tags.category(cat.id)] : [tags.posts]);
      if (catSlug && !cat) next = { kind: "404" };
      else
        next = {
          kind: "list",
          posts: res.status === 200 ? (res.body as Parameters<typeof toCard>[0][]).map((p) => toCard(p, d)) : [],
          categories: cats,
          activeCat: catSlug,
          search,
        };
    } else {
      const slug = parts[1]!;
      const res = run(`/wp/v2/posts?slug=${encodeURIComponent(slug)}${draft ? "&status=publish,draft" : ""}&_embed`, [tags.post(slug)]);
      const list = res.status === 200 ? (res.body as Parameters<typeof toCard>[0][]) : [];
      next = list[0] ? { kind: "post", post: toCard(list[0], d) } : { kind: "404" };
    }

    const status = next.kind === "404" ? 404 : 200;
    push(`  ← ${status} ${hits.every((h) => h.startsWith("HIT")) ? "(served from cache, no WordPress call)" : "(rendered)"}`, status === 200 ? "ok" : "warn");
    setRoute(path);
    setView(next);
    setRenderInfo({ cache: hits, at: new Date(now()).toLocaleTimeString() });
    setExchanges(ex);
    setState((s) => ({ ...s, cache: c }));
  }

  // First render once storage has loaded.
  useEffect(() => {
    if (loaded && !view) render("/blog");
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [loaded, view]);

  /** The WordPress plugin's transition_post_status hook → POST /api/revalidate on the Next.js app. */
  function fireWebhook(d: WpDb, c: DataCache, payload: { post_id: number; slug: string; old_status: string; new_status: string; categories: number[] }) {
    if (!state.webhookOn) {
      push(`transition_post_status ${payload.old_status} → ${payload.new_status}: webhook disabled, page stays stale until revalidate (${3600}s)`, "warn");
      return c;
    }
    push(`do_action('transition_post_status', '${payload.new_status}', '${payload.old_status}', #${payload.post_id})`, "hook");
    push(`wp_remote_post POST /api/revalidate ${JSON.stringify(payload)}`, "info");
    const r = revalidateRoute(c, { "x-revalidate-secret": state.secret }, payload);
    if (r.status !== 200) {
      push(`  ← ${r.status} ${JSON.stringify(r.body)}`, "err");
      return c;
    }
    push(`  ← 200 revalidateTag(${(r.body as { tags: string[] }).tags.join(", ")})`, "ok");
    push(`  purged ${r.purged.length} cached fetch${r.purged.length === 1 ? "" : "es"}${r.purged.length ? ": " + r.purged.join(", ") : ""}`, "dim");
    return r.next;
  }

  function startEdit(id: number | null) {
    const p = id ? db.posts.find((x) => x.id === id) : null;
    setEditingId(id);
    setForm(p ? { title: p.title, excerpt: p.excerpt, content: p.content, categories: p.categories, featured_media: p.featured_media, acf: { ...p.acf } } : EMPTY_DRAFT);
    setFormError(null);
    setNotice(null);
    setScreen("edit");
  }

  function save(status: PostStatus) {
    if (!form.title.trim()) {
      setFormError("Add a title before saving.");
      return;
    }
    if (form.acf.reading_time < 1 || form.acf.reading_time > 60) {
      setFormError("Reading time must be between 1 and 60 minutes (ACF number field limits).");
      return;
    }
    setFormError(null);
    const { db: d, post, oldStatus } = savePost(db, editingId, form, status, new Date(now()).toISOString().slice(0, 19));
    push(`wp_${editingId ? "update" : "insert"}_post #${post.id} "${post.title}" status=${post.status}`, "info");
    let c = cache;
    if (oldStatus === "publish" || status === "publish") {
      c = fireWebhook(d, c, { post_id: post.id, slug: post.slug, old_status: oldStatus, new_status: status, categories: post.categories });
    }
    setState((s) => ({ ...s, db: d, cache: c }));
    setEditingId(post.id);
    setNotice({
      type: "success",
      text: status === "publish" ? (oldStatus === "publish" ? "Post updated." : "Post published.") : "Draft saved.",
    });
    // A visitor reloads the page they are on: purged entries are refetched (MISS), the rest stay HIT.
    render(status === "publish" && oldStatus !== "publish" ? `/blog/${post.slug}` : route, { db: d, cache: c });
  }

  function trash(id: number) {
    const p = db.posts.find((x) => x.id === id);
    if (!p) return;
    const d = trashPost(db, id);
    push(`wp_trash_post #${id}`, "info");
    let c = cache;
    if (p.status === "publish") c = fireWebhook(d, c, { post_id: id, slug: p.slug, old_status: "publish", new_status: "trash", categories: p.categories });
    setState((s) => ({ ...s, db: d, cache: c }));
    setNotice({ type: "success", text: `“${p.title}” moved to the Trash.` });
    render(route === `/blog/${p.slug}` ? "/blog" : route, { db: d, cache: c });
  }

  function preview(id: number) {
    push(`preview_post_link → /api/preview?secret=…&id=${id}`, "hook");
    const r = previewRoute(db, state.secret, id);
    if (r.status !== 307) {
      push(`  ← ${r.status} ${JSON.stringify(r.body)}`, "err");
      setNotice({ type: "error", text: `Preview failed: ${r.status}. Check the shared secret in Settings.` });
      return;
    }
    push(`  ← 307 draftMode().enable(), Location: ${r.location}`, "ok");
    setDraftMode(true);
    render(r.location!, { draft: true });
  }

  function exitPreview() {
    push("GET /api/exit-preview → draftMode().disable()", "info");
    setDraftMode(false);
    render(route.startsWith("/blog/") && view?.kind === "post" && view.post.status !== "publish" ? "/blog" : route, { draft: false });
  }

  function runConsole() {
    const r = handle(db, { method: "GET", path: consolePath, auth: consoleAuth });
    setConsoleResult({ label: "console", path: consolePath, response: r });
    push(`console GET /wp-json${consolePath}${consoleAuth ? " (Authorization: Basic …)" : ""} → ${r.status}`, r.status < 300 ? "ok" : "err");
  }

  function reset() {
    resetState();
    setLog([]);
    setDraftMode(false);
    setClockHours(0);
    setScreen("posts");
    setNotice(null);
    setRoute("/blog");
    setView(null); // the effect above re-renders /blog against the fresh seed
  }

  const cacheEntries = Object.values(cache);
  const shown = consoleResult && tab === "console" ? [consoleResult] : exchanges;

  return (
    <div className="space-y-4 p-3 sm:p-5">
      {/* Toolbar */}
      <div className="flex flex-wrap items-center gap-2 text-xs">
        <span className="font-mono text-fg-muted">clock +{clockHours}h</span>
        <button type="button" onClick={() => setClockHours((h) => h + 1)} className="rounded border border-border px-2 py-1 font-mono hover:border-accent">
          fast-forward 1 hour
        </button>
        <button type="button" onClick={reset} className="rounded border border-border px-2 py-1 font-mono hover:border-accent">
          reset demo
        </button>
        <span className="text-fg-muted">Cached fetches expire after 1 hour even if a webhook is missed.</span>
      </div>

      <div className="grid gap-4 xl:grid-cols-2">
        {/* ---------------- wp-admin ---------------- */}
        <WpAdminFrame
          site="Acme Journal (cms.acme-journal.test)"
          user="Dana Editor"
          active={screen === "edit" ? "posts" : screen}
          onSelect={(id) => (id === "new" ? startEdit(null) : setScreen(id as "posts" | "settings"))}
          menu={[
            { id: "posts", label: "Posts", icon: "✎" },
            { id: "new", label: "Add New", icon: "+" },
            { id: "settings", label: "Headless", icon: "⚙" },
          ]}
          title={screen === "posts" ? "Posts" : screen === "settings" ? "Headless settings" : editingId ? "Edit Post" : "Add New Post"}
          actions={
            screen === "posts" ? (
              <button type="button" className={wpBtn} onClick={() => startEdit(null)}>
                Add New
              </button>
            ) : null
          }
        >
          {notice && <WpNotice type={notice.type}>{notice.text}</WpNotice>}

          {screen === "posts" && (
            <div className="overflow-x-auto">
              <table className="w-full border border-border bg-bg-elev text-left text-[13px]">
                <caption className="sr-only">All posts</caption>
                <thead className="border-b border-border text-fg-muted">
                  <tr>
                    <th className="px-2 py-1.5 font-normal">Title</th>
                    <th className="px-2 py-1.5 font-normal">Category</th>
                    <th className="px-2 py-1.5 font-normal">Date</th>
                  </tr>
                </thead>
                <tbody>
                  {db.posts.length === 0 && (
                    <tr>
                      <td colSpan={3} className="px-2 py-4 text-center text-fg-muted">
                        No posts found.
                      </td>
                    </tr>
                  )}
                  {[...db.posts]
                    .sort((a, b) => b.date.localeCompare(a.date))
                    .map((p) => (
                      <tr key={p.id} className="border-b border-border align-top">
                        <td className="px-2 py-1.5">
                          <button type="button" onClick={() => startEdit(p.id)} className="text-left font-semibold text-accent-2 hover:underline">
                            {p.title}
                          </button>
                          {p.status !== "publish" && <span className="font-semibold text-fg-muted"> — Draft</span>}
                          <div className="mt-0.5 flex gap-2 text-[12px]">
                            <button type="button" onClick={() => startEdit(p.id)} className="text-accent-2 hover:underline">
                              Edit
                            </button>
                            <button type="button" onClick={() => preview(p.id)} className="text-accent-2 hover:underline">
                              Preview
                            </button>
                            <button type="button" onClick={() => trash(p.id)} className="text-danger hover:underline" aria-label={`Trash ${p.title}`}>
                              Trash
                            </button>
                          </div>
                        </td>
                        <td className="px-2 py-1.5 text-fg-muted">{db.categories.filter((c) => p.categories.includes(c.id)).map((c) => c.name).join(", ")}</td>
                        <td className="px-2 py-1.5 whitespace-nowrap text-fg-muted">
                          {p.status === "publish" ? "Published" : "Last modified"}
                          <br />
                          {p.date.slice(0, 10)}
                        </td>
                      </tr>
                    ))}
                </tbody>
              </table>
            </div>
          )}

          {screen === "edit" && (
            <form
              className="space-y-3"
              noValidate
              onSubmit={(e) => {
                e.preventDefault();
                save("publish");
              }}
            >
              <label className="block">
                <span className="sr-only">Title</span>
                <input
                  value={form.title}
                  onChange={(e) => setForm({ ...form, title: e.target.value })}
                  placeholder="Add title"
                  aria-invalid={!!formError && !form.title.trim()}
                  className="w-full rounded border border-border bg-bg-elev px-3 py-2 text-lg outline-none focus:border-accent-2"
                />
              </label>
              <label className="block">
                <span className="text-[12px] text-fg-muted">Content (HTML from the block editor)</span>
                <textarea
                  value={form.content}
                  onChange={(e) => setForm({ ...form, content: e.target.value })}
                  rows={4}
                  className="mt-1 w-full rounded border border-border bg-bg-elev px-3 py-2 font-mono text-[12px] outline-none focus:border-accent-2"
                />
              </label>
              <label className="block">
                <span className="text-[12px] text-fg-muted">Excerpt</span>
                <input
                  value={form.excerpt}
                  onChange={(e) => setForm({ ...form, excerpt: e.target.value })}
                  className="mt-1 w-full rounded border border-border bg-bg-elev px-3 py-1.5 outline-none focus:border-accent-2"
                />
              </label>
              <div className="grid gap-3 sm:grid-cols-2">
                <label className="block">
                  <span className="text-[12px] text-fg-muted">Category</span>
                  <select
                    value={form.categories[0] ?? 4}
                    onChange={(e) => setForm({ ...form, categories: [Number(e.target.value)] })}
                    className="mt-1 w-full rounded border border-border bg-bg-elev px-2 py-1.5"
                  >
                    {db.categories.map((c) => (
                      <option key={c.id} value={c.id}>
                        {c.name}
                      </option>
                    ))}
                  </select>
                </label>
                <label className="block">
                  <span className="text-[12px] text-fg-muted">Featured image</span>
                  <select
                    value={form.featured_media}
                    onChange={(e) => setForm({ ...form, featured_media: Number(e.target.value) })}
                    className="mt-1 w-full rounded border border-border bg-bg-elev px-2 py-1.5"
                  >
                    <option value={0}>None</option>
                    {db.media.map((m) => (
                      <option key={m.id} value={m.id}>
                        {m.alt_text}
                      </option>
                    ))}
                  </select>
                </label>
              </div>
              <fieldset className="rounded border border-border bg-bg-elev p-3">
                <legend className="px-1 text-[12px] font-semibold">ACF · Post details</legend>
                <div className="grid gap-3 sm:grid-cols-[1fr_8rem]">
                  <label className="block">
                    <span className="text-[12px] text-fg-muted">Subtitle</span>
                    <input
                      value={form.acf.subtitle}
                      maxLength={80}
                      onChange={(e) => setForm({ ...form, acf: { ...form.acf, subtitle: e.target.value } })}
                      className="mt-1 w-full rounded border border-border bg-bg px-2 py-1.5 outline-none focus:border-accent-2"
                    />
                  </label>
                  <label className="block">
                    <span className="text-[12px] text-fg-muted">Reading time</span>
                    <input
                      type="number"
                      min={1}
                      max={60}
                      value={form.acf.reading_time}
                      onChange={(e) => setForm({ ...form, acf: { ...form.acf, reading_time: Number(e.target.value) } })}
                      className="mt-1 w-full rounded border border-border bg-bg px-2 py-1.5 outline-none focus:border-accent-2"
                    />
                  </label>
                </div>
                <label className="mt-2 flex items-center gap-2 text-[13px]">
                  <input
                    type="checkbox"
                    checked={form.acf.show_toc}
                    onChange={(e) => setForm({ ...form, acf: { ...form.acf, show_toc: e.target.checked } })}
                  />
                  Show table of contents
                </label>
              </fieldset>
              {formError && (
                <p role="alert" className="text-sm text-danger">
                  {formError}
                </p>
              )}
              <div className="flex flex-wrap gap-2">
                <button type="button" className={wpBtn} onClick={() => save("draft")}>
                  {editingId && db.posts.find((p) => p.id === editingId)?.status === "publish" ? "Switch to draft" : "Save draft"}
                </button>
                {editingId && (
                  <button type="button" className={wpBtn} onClick={() => preview(editingId)}>
                    Preview
                  </button>
                )}
                <button type="submit" className={wpBtnPrimary}>
                  {editingId && db.posts.find((p) => p.id === editingId)?.status === "publish" ? "Update" : "Publish"}
                </button>
              </div>
              <p className="text-[12px] text-fg-muted">Tip: Preview saves nothing publicly. Save the draft first to preview your latest edits.</p>
            </form>
          )}

          {screen === "settings" && (
            <div className="space-y-3 text-[13px]">
              <label className="flex items-center gap-2">
                <input type="checkbox" checked={state.webhookOn} onChange={(e) => setState((s) => ({ ...s, webhookOn: e.target.checked }))} />
                Send revalidation webhook on publish (transition_post_status)
              </label>
              <label className="block">
                <span className="text-fg-muted">ACME_REVALIDATE_SECRET (wp-config.php)</span>
                <input
                  value={state.secret}
                  onChange={(e) => setState((s) => ({ ...s, secret: e.target.value }))}
                  className="mt-1 w-full rounded border border-border bg-bg-elev px-2 py-1.5 font-mono text-[12px] outline-none focus:border-accent-2"
                />
              </label>
              <p className="text-fg-muted">
                Change one character and publish: Next.js answers <code>401</code> and the page stays stale. Turn the webhook off to see the
                one-hour fallback instead.
              </p>
              <button type="button" className={wpBtn} onClick={() => setState((s) => ({ ...s, secret: REVALIDATE_SECRET }))}>
                Restore secret
              </button>
            </div>
          )}
        </WpAdminFrame>

        {/* ---------------- Next.js front end ---------------- */}
        <section aria-label="Next.js front end" className="overflow-hidden rounded-md border border-border">
          <div className="flex items-center gap-2 border-b border-border bg-bg-muted px-2 py-1.5">
            <button type="button" onClick={() => render(route)} className="rounded px-1.5 font-mono text-sm hover:text-accent" aria-label="Reload page">
              ↻
            </button>
            <span className="min-w-0 flex-1 truncate rounded bg-bg px-2 py-0.5 font-mono text-[12px] text-fg-muted">acme-journal.test{route}</span>
            {renderInfo && (
              <span
                className={`rounded px-1.5 font-mono text-[10px] ${renderInfo.cache.every((c) => c.startsWith("HIT")) ? "bg-accent-soft text-accent" : "bg-warn-bg text-warn-fg"}`}
                title={renderInfo.cache.join("\n")}
              >
                {draftMode ? "DRAFT" : renderInfo.cache.every((c) => c.startsWith("HIT")) ? "CACHE HIT" : "REGENERATED"}
              </span>
            )}
          </div>
          {draftMode && (
            <div className="flex items-center justify-between gap-2 bg-[#f59e0b] px-3 py-1 text-[12px] text-[#1c1917]">
              <span>Preview mode: you are seeing drafts. The cache is bypassed.</span>
              <button type="button" onClick={exitPreview} className="rounded border border-[#1c1917]/40 px-2 font-semibold hover:bg-[#1c1917]/10">
                Exit preview
              </button>
            </div>
          )}
          <div aria-live="polite" className="max-h-[34rem] min-h-[24rem] overflow-y-auto bg-bg-elev p-4">
            <header className="mb-4 flex flex-wrap items-center gap-3 border-b border-border pb-3">
              <button type="button" onClick={() => render("/blog")} className="font-bold tracking-tight">
                Acme<span className="text-accent">.</span>journal
              </button>
              <form
                className="ml-auto flex gap-1"
                role="search"
                onSubmit={(e) => {
                  e.preventDefault();
                  render(searchBox.trim() ? `/blog?q=${encodeURIComponent(searchBox.trim())}` : "/blog");
                }}
              >
                <label className="sr-only" htmlFor="hl-search">
                  Search posts
                </label>
                <input
                  id="hl-search"
                  value={searchBox}
                  onChange={(e) => setSearchBox(e.target.value)}
                  placeholder="Search…"
                  className="w-28 rounded border border-border bg-bg px-2 py-1 text-xs outline-none focus:border-accent sm:w-40"
                />
              </form>
            </header>

            {!view && <p className="text-sm text-fg-muted">Loading…</p>}
            {view?.kind === "404" && (
              <div className="py-10 text-center">
                <p className="font-mono text-4xl font-bold">404</p>
                <p className="mt-2 text-sm text-fg-muted">notFound(): WordPress returned no published post for this URL.</p>
                <button type="button" onClick={() => render("/blog")} className="mt-3 text-sm text-accent hover:underline">
                  ← All posts
                </button>
              </div>
            )}
            {view?.kind === "list" && (
              <div>
                <nav aria-label="Categories" className="mb-4 flex flex-wrap gap-1.5">
                  <button
                    type="button"
                    onClick={() => render("/blog")}
                    className={`rounded-full border px-2.5 py-0.5 text-xs ${!view.activeCat ? "border-accent text-accent" : "border-border text-fg-muted hover:text-fg"}`}
                  >
                    All
                  </button>
                  {view.categories.map((c) => (
                    <button
                      type="button"
                      key={c.id}
                      onClick={() => render(`/blog/category/${c.slug}`)}
                      className={`rounded-full border px-2.5 py-0.5 text-xs ${view.activeCat === c.slug ? "border-accent text-accent" : "border-border text-fg-muted hover:text-fg"}`}
                    >
                      {c.name} <span className="opacity-60">{c.count}</span>
                    </button>
                  ))}
                </nav>
                {view.search && <p className="mb-3 text-xs text-fg-muted">Results for “{view.search}”</p>}
                {view.posts.length === 0 && <p className="py-6 text-center text-sm text-fg-muted">No posts found.</p>}
                <ul className="space-y-4">
                  {view.posts.map((p) => (
                    <li key={p.id}>
                      <article className="flex gap-3">
                        <Thumb image={p.image} className="h-16 w-24 shrink-0" />
                        <div className="min-w-0">
                          <p className="text-[11px] text-fg-muted">
                            {p.category} · {p.date} · {p.readingTime} min
                            {p.status !== "publish" && <span className="ml-1 rounded bg-warn-bg px-1 text-warn-fg">draft</span>}
                          </p>
                          <h4 className="font-semibold leading-snug">
                            <button type="button" onClick={() => render(`/blog/${p.slug}`)} className="text-left hover:text-accent">
                              {p.title}
                            </button>
                          </h4>
                          <p className="line-clamp-2 text-sm text-fg-muted">{p.excerpt}</p>
                        </div>
                      </article>
                    </li>
                  ))}
                </ul>
              </div>
            )}
            {view?.kind === "post" && (
              <article>
                <button type="button" onClick={() => render("/blog")} className="text-xs text-fg-muted hover:text-accent">
                  ← All posts
                </button>
                <p className="mt-3 text-[11px] text-fg-muted">
                  {view.post.category} · {view.post.date} · {view.post.author} · {view.post.readingTime} min read
                </p>
                <h4 className="mt-1 text-2xl font-bold leading-tight tracking-tight">{view.post.title}</h4>
                {view.post.subtitle && <p className="mt-1 text-fg-muted">{view.post.subtitle}</p>}
                <Thumb image={view.post.image} className="mt-3 aspect-[16/9] w-full" />
                {view.post.showToc && headings(view.post.content).length > 0 && (
                  <nav aria-label="Table of contents" className="mt-3 rounded border border-border p-2 text-sm">
                    <p className="text-[11px] text-fg-muted">On this page</p>
                    <ul className="list-inside list-disc">
                      {headings(view.post.content).map((h) => (
                        <li key={h}>{h}</li>
                      ))}
                    </ul>
                  </nav>
                )}
                <div className="prose prose-sm mt-3 max-w-none" dangerouslySetInnerHTML={{ __html: sanitizeHtml(view.post.content) }} />
              </article>
            )}
          </div>
        </section>
      </div>

      {/* ---------------- Inspector ---------------- */}
      <div className="grid gap-4 lg:grid-cols-[1.4fr_1fr]">
        <section aria-label="Inspector" className="min-w-0">
          <div role="tablist" aria-label="Inspector views" className="flex gap-1 border-b border-border">
            {(
              [
                ["response", "API response"],
                ["console", "API console"],
                ["cache", `Data cache (${cacheEntries.length})`],
              ] as const
            ).map(([id, label]) => (
              <button
                key={id}
                type="button"
                role="tab"
                aria-selected={tab === id}
                onClick={() => setTab(id)}
                className={`-mb-px border-b-2 px-3 py-1.5 font-mono text-xs ${tab === id ? "border-accent text-accent" : "border-transparent text-fg-muted hover:text-fg"}`}
              >
                {label}
              </button>
            ))}
          </div>

          {tab === "console" && (
            <form
              className="mt-3 flex flex-wrap items-end gap-2"
              onSubmit={(e) => {
                e.preventDefault();
                runConsole();
              }}
            >
              <label className="min-w-0 flex-1">
                <span className="text-xs text-fg-muted">GET /wp-json</span>
                <input
                  list="hl-presets"
                  value={consolePath}
                  onChange={(e) => setConsolePath(e.target.value)}
                  className="mt-1 w-full rounded border border-border bg-bg-elev px-2 py-1.5 font-mono text-xs outline-none focus:border-accent"
                />
                <datalist id="hl-presets">
                  {CONSOLE_PRESETS.map((p) => (
                    <option key={p} value={p} />
                  ))}
                </datalist>
              </label>
              <label className="flex items-center gap-1.5 text-xs">
                <input type="checkbox" checked={consoleAuth} onChange={(e) => setConsoleAuth(e.target.checked)} />
                Application password
              </label>
              <button type="submit" className="rounded bg-accent px-3 py-1.5 text-xs font-medium text-accent-fg hover:opacity-90">
                Send
              </button>
            </form>
          )}

          {tab === "cache" ? (
            <div className="mt-3 overflow-x-auto">
              {cacheEntries.length === 0 ? (
                <p className="text-sm text-fg-muted">Empty. Load a page to fill it.</p>
              ) : (
                <table className="w-full text-left font-mono text-[11px]">
                  <caption className="sr-only">Cached fetches with their tags</caption>
                  <thead className="text-fg-muted">
                    <tr className="border-b border-border">
                      <th className="py-1 pr-2 font-normal">fetch</th>
                      <th className="py-1 pr-2 font-normal">tags</th>
                      <th className="py-1 font-normal">age</th>
                    </tr>
                  </thead>
                  <tbody>
                    {cacheEntries.map((e) => (
                      <tr key={e.url} className="border-b border-border align-top">
                        <td className="py-1 pr-2 break-all">{e.url}</td>
                        <td className="py-1 pr-2 text-accent-2">{e.tags.join(" ")}</td>
                        <td className="py-1 whitespace-nowrap text-fg-muted">
                          <Age storedAt={e.storedAt} offsetHours={clockHours} />
                        </td>
                      </tr>
                    ))}
                  </tbody>
                </table>
              )}
            </div>
          ) : (
            <div className="mt-3 space-y-3">
              {shown.length === 0 && <p className="text-sm text-fg-muted">{tab === "console" ? "Pick an endpoint and press Send." : "No requests yet."}</p>}
              {shown.map((x, i) => (
                <figure key={x.path + i} className="overflow-hidden rounded-md border border-border">
                  <figcaption className="flex flex-wrap items-center gap-2 border-b border-[#27272a] bg-[#18181b] px-3 py-1.5 font-mono text-[11px] text-[#a1a1aa]">
                    <span className={x.response.status < 300 ? "text-[#4ade80]" : "text-[#f87171]"}>{x.response.status}</span>
                    <span className="min-w-0 flex-1 truncate">GET /wp-json{x.path}</span>
                    {x.cache && <span className="text-[#fbbf24]">{x.cache}</span>}
                  </figcaption>
                  <pre className="max-h-72 overflow-auto bg-[#0b0b0d] p-3 font-mono text-[11px] leading-relaxed text-[#e4e4e7]">
                    {Object.entries(x.response.headers)
                      .map(([k, v]) => `${k}: ${v}`)
                      .join("\n")}
                    {"\n\n"}
                    {JSON.stringify(x.response.body, null, 2)}
                  </pre>
                </figure>
              ))}
            </div>
          )}
        </section>
        <LogPanel lines={log} label="event log (WordPress → Next.js)" empty="$ load a page or publish a post…" className="h-[26rem]" />
      </div>
    </div>
  );
}

function Thumb({ image, className }: { image: CardPost["image"]; className: string }) {
  if (!image) return <div className={`rounded bg-bg-muted ${className}`} aria-hidden="true" />;
  return (
    <div
      role="img"
      aria-label={image.alt}
      className={`rounded ${className}`}
      style={{ background: `linear-gradient(135deg, hsl(${image.hue} 60% 45%), hsl(${(image.hue + 50) % 360} 55% 30%))` }}
    />
  );
}

function Age({ storedAt, offsetHours }: { storedAt: number; offsetHours: number }) {
  const [now, setNow] = useState<number | null>(null);
  useEffect(() => {
    setNow(Date.now());
    const t = setInterval(() => setNow(Date.now()), 5000);
    return () => clearInterval(t);
  }, []);
  if (now === null) return null;
  const s = Math.max(0, Math.round((now + offsetHours * 3_600_000 - storedAt) / 1000));
  return <>{s < 60 ? `${s}s` : s < 3600 ? `${Math.floor(s / 60)}m` : `${Math.floor(s / 3600)}h (stale)`}</>;
}