Skip to content
~sami-ullah

Concept project

Headless WordPress and Next.js Blog

A concept project with a working demo: editors write in WordPress, readers get a Next.js site. Publishing a post fires a webhook that purges tagged cache entries, drafts open through Draft Mode, and ACF fields come through the REST API. The demo simulates both sides in the browser and shows every REST response.

Screenshot of the Headless WordPress and Next.js Blog demo

Overview#

Headless WordPress means WordPress keeps the editing and a separate front end serves the pages. Editors keep the admin they know. Readers get static pages from a CDN. The hard parts are not fetching posts; they are keeping the cache fresh when an editor presses Publish, letting editors preview drafts, and not leaking drafts to the public.

The live demo shows both halves side by side: a mini wp-admin on the left, the Next.js front end on the right, and an inspector underneath with the raw REST responses, the data cache and an event log of everything that crosses between them.

My role#

Everything: the API module, the cache and webhook design, the WordPress plugin in PHP, the demo interface and this write-up. It is a concept project, not client work.

The problem#

A plain Next.js site with WordPress behind it usually ends up in one of two bad states:

  • Too fresh. Every page request calls WordPress. That is a PHP request and several database queries per visit, and WordPress becomes the bottleneck again.
  • Too stale. Pages are cached with a long timer. An editor fixes a typo, refreshes, sees the old text and files a bug.

What editors expect is simple: press Publish, see the change within seconds, and preview drafts before anyone else can see them.

Approach & architecture#

◆ Publish
  1. wp-admin Publishthen
  2. transition_post_statusthen
  3. POST /api/revalidatethen
  4. revalidateTagthen
  5. next visit regenerates
◆ Read path
  1. Visitorthen
  2. Next.js pagethen
  3. tagged fetch (cache HIT)then
  4. HTML
  1. tagged fetch (MISS)then
  2. /wp-json/wp/v2/posts?_embedthen
  3. cache store with tags

Tagged fetches. Each fetch in the Next.js app carries cache tags, defined in one place so the pages and the webhook agree on names:

ts
export const tags = {
  posts: "wp:posts",
  post: (slug: string) => `wp:post:${slug}`,
  category: (id: number) => `wp:category:${id}`,
  categories: "wp:categories",
};

The list page fetches /wp/v2/posts?_embed tagged wp:posts. A post page fetches ?slug=… tagged wp:post:<slug>. _embed brings the author, featured image and terms in one request instead of three.

One hook for every change. In the production design a small plugin listens to transition_post_status. It fires on publish, update, unpublish and trash, so one callback covers all four. It only acts when the old or new status is publish, because draft edits do not change public pages.

php
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 ) ) {
		return;
	}
	if ( 'publish' !== $new_status && 'publish' !== $old_status ) {
		return;
	}
	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( array(
			'post_id'    => $post->ID,
			'slug'       => $post->post_name,
			'old_status' => $old_status,
			'new_status' => $new_status,
			'categories' => wp_get_post_categories( $post->ID ),
		) ),
		'blocking' => false,
	) );
}

The request is non-blocking, so the editor never waits on the front end's host.

The revalidate route checks the shared secret, validates the body and purges four kinds of tag: the post, the list, the category archives and the category counts. In the demo, revalidateRoute in next-site.ts is that handler, and the Data cache tab lets you watch entries disappear.

ACF in REST. The custom fields (subtitle, reading time, a table-of-contents switch) are registered in PHP with acf_add_local_field_group and show_in_rest, so they live in version control and arrive under acf in the post JSON.

Key challenges#

  • Drafts must never reach the public cache. Draft Mode bypasses the cache entirely and fetches with credentials. In the demo, try /wp/v2/posts?status=draft in the API console without the application password: you get the same 400 rest_invalid_param WordPress returns.
  • Lost webhooks. Networks fail. Every tagged fetch also has a one-hour revalidate, so the worst case is an hour of staleness, not forever. The demo has a clock to fast-forward and a switch to turn the webhook off.
  • Previews from the WordPress button. The preview_post_link filter points the editor's Preview button at the Next.js /api/preview route with a secret and the post id. The route checks the post exists, enables Draft Mode and redirects.
  • Untrusted HTML. Post content is HTML. It is passed through an allow-list sanitiser before rendering, so stored markup cannot add scripts or event handlers.
  • The old theme front end. A template_redirect hook sends any theme request to the same path on the Next.js domain with a 301, so search engines see one site.

Tech stack#

  • Demo: TypeScript, React and Next.js. wp-api.ts is a mock of the WordPress REST API with core's response shapes, headers (X-WP-Total) and error codes. next-site.ts models the tagged data cache and the two route handlers. State is saved in localStorage.
  • Production design: WordPress with a small PHP plugin (shown in the demo's source), ACF for custom fields, the built-in REST API, and a Next.js App Router site on Vercel using fetch tags, revalidateTag and Draft Mode.

Results#

  • A working simulation of the full publish loop: edit, publish, webhook, tag purge, regenerated page, with each step written to the event log.
  • Previews of drafts that stay out of the cache, and an exit button that returns to the public view.
  • A REST console that returns the same JSON shapes and error codes as core WordPress, including _embed, pagination headers and acf.
  • The production plugin is written out in PHP and listed under "View source" on the demo page.

This is a concept build; there is no deployed WordPress install or real readership behind it.

Try the demo#

Open the headless WordPress demo. Things to try:

  1. Load the blog, then reload it: the badge changes from REGENERATED to CACHE HIT. Edit a post title, press Update, and see only that post's tags purged.
  2. Press Preview on the draft post. The yellow bar shows Draft Mode; exit it and the draft URL returns 404.
  3. In Headless settings change the secret, publish something, and read the 401 in the event log.
  4. In the API console, send /wp/v2/posts?per_page=500 and /wp/v2/pages to see WordPress-style errors.

What I would do next#

  • Run it against a real WordPress install in Docker, with Playwright tests that publish a post and assert the page changes.
  • Sign the webhook body with HMAC and a timestamp instead of a static header, to stop replays.
  • Add a sitemap and RSS route in Next.js that use the same wp:posts tag.
Full screenshot of the Headless WordPress and Next.js Blog demo running in the browser
Screenshot of the live demo with its sample data. Click to open it.
▶ live demoPublish a post in wp-admin and watch the webhook purge cache tags and the Next.js page regenerate. Preview drafts too.

Skills used

#nextjs6 min

ISR vs SSG vs SSR in Next.js 15: How to Choose

ISR vs SSG vs SSR in Next.js 15 App Router: what each rendering mode is, how Next.js picks one, a comparison table, working code and a decision checklist.

read →

#nextjs6 min

Next.js App Router SEO Checklist for Next.js 15

A practical Next.js App Router SEO checklist for Next.js 15: metadata, canonicals, sitemap, robots, JSON-LD, rendering, images and 404s, with working code.

read →