Enterprise WordPress · 6 min read

Headless WordPress with Next.js: Preview, Caching and Deploys

Draft preview, invalidating caches on publish, and shipping two codebases in a compatible order - the three problems that decide if headless works.

By Praful Patel · Last updated
Headless WordPress with Next.js: Preview, Caching and Deploys - article cover
Headless WordPress with Next.js: Preview, Caching and Deploys - article cover

Short answer

Fetching WordPress content into Next.js takes an afternoon. The three problems that decide whether the build succeeds are draft preview, cache invalidation on publish, and shipping two codebases in a compatible order. Every headless WordPress project that editors end up disliking failed at one of those three, not at the data fetching.

Going headless removes features that were previously free. This is the practical account of restoring them.

Problem 1: preview

Editors expect to click Preview and see their unpublished change. In a decoupled setup the WordPress preview link points at a PHP theme that no longer renders the site.

The pieces: rewrite the preview link to point at the front end, authenticate the request, fetch draft content, and disable caching on that path.

// functions.php - send Preview to the front end with a shared secret.
add_filter( 'preview_post_link', function ( $link, $post ) {
	return add_query_arg(
		array(
			'secret' => rawurlencode( ACME_PREVIEW_SECRET ),
			'id'     => $post->ID,
		),
		ACME_FRONTEND_URL . '/api/preview'
	);
}, 10, 2 );
// app/api/preview/route.ts
export async function GET(request: Request) {
  const { searchParams } = new URL(request.url);

  // Constant-time comparison; a leaked secret grants draft access.
  if (!timingSafeEqual(searchParams.get('secret'), process.env.PREVIEW_SECRET)) {
    return new Response('Invalid token', { status: 401 });
  }

  (await draftMode()).enable();
  redirect(`/preview/${searchParams.get('id')}`);
}

Then in the render path, draft mode must both authenticate to WordPress and bypass every cache:

const { isEnabled } = await draftMode();

const post = await fetchPost(id, {
  auth:  isEnabled ? applicationPassword : undefined,
  cache: isEnabled ? 'no-store' : 'force-cache',
});

The failure people report as “preview is broken” is almost always a cached response being served to the preview route.

Problem 2: invalidation on publish

Static rendering is why headless is fast. It also means publishing changes nothing until something tells the front end. A webhook on WordPress save closes the loop.

add_action( 'transition_post_status', function ( $new, $old, $post ) {
	if ( 'publish' !== $new && 'publish' !== $old ) {
		return;   // ignore draft-to-draft churn
	}

	wp_remote_post( ACME_FRONTEND_URL . '/api/revalidate', array(
		'timeout'  => 5,
		'blocking' => false,          // never make an editor wait on this
		'headers'  => array( 'x-acme-signature' => hash_hmac( 'sha256', (string) $post->ID, ACME_REVALIDATE_SECRET ) ),
		'body'     => array( 'id' => $post->ID, 'slug' => $post->post_name, 'type' => $post->post_type ),
	) );
}, 10, 3 );

'blocking' => false matters: a slow or unreachable front end must not make the WordPress admin hang on save.

On the receiving side, revalidate the specific page and the listings that include it:

revalidatePath(`/blog/${slug}`);
revalidateTag('posts');          // archives, sitemaps, related lists

The archive invalidation is the commonly missed half — the post updates, the index still shows the old title. Keep a time-based backstop too, so a dropped webhook degrades to stale rather than permanently wrong. The layers involved are described in Next.js Caching Explained.

Problem 3: two deployments, one release

An API change in WordPress and its consumer in Next.js must ship in a compatible order. The rule that avoids coordinated deploys: additive changes first.

  • Add the new field to the API and deploy WordPress. Nothing consumes it yet.
  • Deploy the front end reading the new field with a fallback to the old one.
  • Remove the old field once nothing reads it.

Renaming a field in one deploy breaks production in the window between the two. Version custom REST namespaces (acme/v1, acme/v2) so a breaking change is an added route rather than a modified one.

What else moves to the front end

Concern Was Now
Titles and meta descriptions SEO plugin output Fetched, rendered by your metadata layer
Schema / JSON-LD Plugin Front end, from API data
Sitemap Plugin route Generated route in Next.js
Redirects Plugin table Read from API into config or middleware
Forms Plugin shortcode Server action or route handler
Comments Theme templates Custom UI over the REST endpoints

None of it is difficult. All of it is work that was previously free, and it belongs in the estimate. The SEO consequences specifically are covered in Migrating WordPress to Headless Without Losing SEO.

Lock down the WordPress instance

Once WordPress is only an API, treat it that way. Block front-end theme routes, disable XML-RPC if unused, restrict the admin to known addresses or an SSO layer, and audit which REST routes are exposed — /wp-json/wp/v2/users enumerating authors is a default worth changing. The relevant discipline is in WordPress Security Hardening.

Keeping the API contract stable

The API between WordPress and the front end is a real interface, and it deserves the same treatment as any other. Two practices prevent most breakage.

Shape the response deliberately. Returning raw WP_Post objects couples the front end to WordPress’s internal structure, including fields it should never care about. A mapping layer gives you a stable contract you control:

function acme_article_shape( WP_Post $post ): array {
	return array(
		'id'       => $post->ID,
		'slug'     => $post->post_name,
		'title'    => get_the_title( $post ),
		'excerpt'  => get_the_excerpt( $post ),
		'html'     => apply_filters( 'the_content', $post->post_content ),
		'modified' => get_post_modified_time( 'c', true, $post ),
		'seo'      => acme_seo_fields( $post->ID ),
	);
}

Type it on the other side. Generate or hand-write TypeScript types for that shape and validate responses at the boundary in development. A field silently disappearing because a plugin was deactivated should be a loud failure in CI, not a blank section discovered by a reader.

Media and images

Images come from WordPress’s uploads directory, which is a different origin to the front end. Two things follow: add that host to the image configuration so optimisation is allowed to touch it, and decide who resizes. WordPress already generates size variants at upload; using those directly avoids paying for the same work twice, and matters more if you are self-hosting the front end, where optimisation is your own CPU.

Questions people actually ask

REST or WPGraphQL for a headless WordPress front end?
REST with purpose-built endpoints for one known front end, because HTTP caching works without extra machinery. WPGraphQL when several consumers need different shapes, with depth limits and persisted queries in place. The comparison is covered in detail in the REST versus WPGraphQL article.

Why is preview showing old content?
A cache in the preview path. Draft reads must use no-store and the route must be dynamic. Check the build output too – if the preview route was statically rendered at build time, it will serve the same content to everyone regardless of draft mode.

Do I still need an SEO plugin on the WordPress side?
Yes, as the editorial interface – editors need somewhere to write titles, descriptions and canonicals. What changes is that the plugin no longer renders anything; the front end reads those values through the API and outputs the tags itself.

Should WordPress stay publicly reachable?
It has to be reachable by your front end’s server, but it does not need to be reachable by the public. Put it behind an allowlist or private network if your hosting permits, block the theme front end, and keep the admin behind SSO. Every public surface is a surface you now maintain for no user benefit.

How do editors know their change went live?
Give them a signal. A revalidation endpoint that returns success, surfaced as an admin notice, or a simple last-deployed timestamp in the admin. Without feedback, publishing feels unreliable even when it is working, and that perception is what turns teams against headless.

← Back to all insights