14+ years building on WordPress / Replies in under 5 hours
Headless WordPress 9 min read · Updated July 2026

Next.js Headless WordPress Performance: A Developer’s Speed Guide

Photo of Ajay Khandal
Ajay Khandal
WordPress Developer
Building a High-Performance Frontend with Next.js and Headless WP
TL;DR

Headless WordPress serves pre-rendered HTML from a CDN, but performance still depends on deliberate choices about rendering strategy and image handling. Use ISR (revalidate: 3600) for most post pages so content refreshes without a full rebuild; switch to on-demand revalidation via webhooks if you need updates live within seconds of a WordPress publish. Configure next.config.js with images.remotePatterns for your WordPress domain, then use next/image with the priority prop on above-the-fold images — this alone fixes the most common LCP regression in headless WordPress sites. In the App Router, prefer Server Components for content-only pages; they ship zero client-side JavaScript for the component itself. Use _fields on all REST API requests to reduce payload size by 60-70%. Run next build after each significant change and investigate any route that exceeds 100kB of client JavaScript.

Headless WordPress is architecturally fast — content is pre-rendered to static HTML and served from a CDN edge node close to the visitor, with no PHP execution on the critical path. But the architecture only gives you the ceiling; hitting it requires deliberate choices about rendering strategy, image handling, data fetching, and bundle size. This guide covers the decisions that actually move the needle on Lighthouse scores and Core Web Vitals for a Next.js + WordPress stack.

If you’re still deciding whether headless is the right approach for your project, the headless WordPress pros, cons and use cases guide covers the trade-offs before you commit to the architecture.

The Rendering Strategy Decision

Next.js gives you four rendering modes, and the one you choose per route determines how fast pages load and how fresh the content is.

Static Site Generation (SSG) builds HTML at deploy time. Every visitor gets the same pre-rendered file from a CDN edge node — Time to First Byte (TTFB) under 50ms is achievable. The downside: content stays frozen until the next deploy. For a WordPress site that publishes several times a week, waiting for a full rebuild on every post update is impractical.

Incremental Static Regeneration (ISR) adds a revalidate interval to SSG. The first visitor after the interval triggers a background regeneration; subsequent visitors get the new version. You keep CDN-speed delivery while content refreshes automatically. A revalidate: 3600 setting means pages are at most one hour stale — acceptable for most blogs, too slow for news or stores.

On-demand revalidation is the best of both: pages are statically cached until a WordPress webhook fires, triggering revalidatePath for exactly the page that changed. Content goes live within seconds of a publish. The webhooks and on-demand revalidation guide covers the full implementation — the WordPress save_post hook, the Next.js revalidation endpoint, and HMAC security.

React Server Components (App Router only) render on the server on every request but ship zero client-side JavaScript for the component itself. For pages that are mostly read-only content, Server Components combined with fetch caching give you near-static speed with request-time freshness.

Mode When HTML is built Freshness Best for
SSG Deploy time Until next deploy Rarely-changing pages (about, pricing)
ISR (time-based) Deploy + background regen Within interval (e.g. 1hr) Blogs, news with tolerance for lag
On-demand revalidation Deploy + on publish webhook Seconds after publish Most WordPress sites
Server Components Every request (cached) Per fetch cache config Dynamic pages needing request-time data

Pages Router: getStaticPaths + ISR

For projects on the Pages Router, getStaticPaths tells Next.js which post slugs to pre-render at build time, and getStaticProps with a revalidate value adds ISR. Use fallback: 'blocking' so that new posts published after the last build are served on first request — Next.js fetches them server-side, caches the result, and serves HTML for all subsequent requests.

// pages/posts/[slug].js  (Pages Router + REST API + ISR)

export async function getStaticPaths() {
  const res = await fetch(
    'https://api.yourdomain.com/wp-json/wp/v2/posts?per_page=100&_fields=slug'
  );
  const posts = await res.json();

  return {
    paths: posts.map((p) => ({ params: { slug: p.slug } })),
    fallback: 'blocking', // serve on first request, then cache
  };
}

export async function getStaticProps({ params }) {
  const res = await fetch(
    `https://api.yourdomain.com/wp-json/wp/v2/posts?slug=${params.slug}&_fields=id,title,content,date,featured_media`
  );
  const [post] = await res.json();

  if (!post) return { notFound: true };

  return {
    props: { post },
    revalidate: 3600, // ISR: regenerate at most once per hour (or use webhooks)
  };
}

The _fields query parameter on the WordPress REST API is a significant performance lever: it limits the response to only the fields you need. Fetching a full post object returns ~30 fields including raw content, rendered content, embedded media objects, and metadata. Specifying _fields=id,title,content,date,featured_media cuts the payload by 60–70%, which matters both for build time and for on-demand revalidation latency.

App Router: generateStaticParams and Server Components

The App Router replaces getStaticPaths with generateStaticParams and getStaticProps with direct fetch calls inside Server Components. The next: { revalidate } option on fetch is the equivalent of the revalidate key in getStaticProps.

// app/posts/[slug]/page.js  (App Router + REST API)

// Pre-render known slugs at build time
export async function generateStaticParams() {
  const posts = await fetch(
    'https://api.yourdomain.com/wp-json/wp/v2/posts?per_page=100&_fields=slug',
    { next: { revalidate: 3600 } }
  ).then((r) => r.json());

  return posts.map((p) => ({ slug: p.slug }));
}

// Page component runs as a React Server Component by default (zero client JS)
export default async function PostPage({ params }) {
  const [post] = await fetch(
    `https://api.yourdomain.com/wp-json/wp/v2/posts?slug=${params.slug}&_fields=id,title,content,date`,
    { next: { revalidate: 3600 } }  // time-based ISR, or omit if using webhooks
  ).then((r) => r.json());

  if (!post) notFound();

  return (
    <article>
      <h1 dangerouslySetInnerHTML={{ __html: post.title.rendered }} />
      <div dangerouslySetInnerHTML={{ __html: post.content.rendered }} />
    </article>
  );
}

The key App Router advantage for a headless WordPress site: Server Components ship zero JavaScript to the browser for the component itself. A post page that renders WordPress HTML content via dangerouslySetInnerHTML sends no client-side bundle for that component — only the HTML string. For content-heavy pages with minimal interactivity, this alone can cut Total Blocking Time to near zero.

The choice between REST API and GraphQL doesn’t change the App Router patterns above — you substitute the fetch call body. For a comparison of which gateway makes sense for different project types, see the REST API vs GraphQL guide.

Image Optimisation: next/image with WordPress Media

WordPress stores full-resolution images; next/image resizes, converts to WebP or AVIF, and lazy-loads them automatically — but it refuses to proxy images from external domains unless you explicitly allow the host in next.config.js.

// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
  images: {
    remotePatterns: [
      {
        protocol: 'https',
        hostname: 'api.yourdomain.com',   // your WordPress backend domain
        pathname: '/wp-content/uploads/**',
      },
    ],
    formats: ['image/avif', 'image/webp'], // serve AVIF first, fallback WebP
  },
};

module.exports = nextConfig;

Once the remote pattern is registered, use next/image for all WordPress featured images:

// Using next/image with a WordPress featured image URL
import Image from 'next/image';

export default function FeaturedImage({ src, alt, width, height }) {
  return (
    <Image
      src={src}           // full URL from WordPress _embedded or featured_media
      alt={alt}
      width={width}
      height={height}
      sizes="(max-width: 768px) 100vw, 800px"
      priority            // add this on above-the-fold images to improve LCP
    />
  );
}

Two decisions here directly affect Largest Contentful Paint (LCP): the sizes attribute tells the browser which image dimensions to request at different viewport widths (prevents downloading a 1200px image on a 375px mobile screen), and the priority prop on the above-the-fold featured image disables lazy loading for it — lazy loading the hero image is one of the most common causes of poor LCP scores on headless WordPress sites.

Core Web Vitals: What Headless Fixes and What It Doesn’t

Headless WordPress addresses a specific class of performance problems: server response time and TTFB (because PHP is no longer in the critical path), render-blocking resources (because Next.js handles CSS and JS bundling with better defaults than most WordPress themes), and image delivery (via next/image). It does not automatically fix:

  • LCP — still depends on image optimisation (priority prop, correct sizes, WebP/AVIF), font loading strategy, and what the first visible content on the page actually is
  • INP (Interaction to Next Paint) — determined by your React component architecture; a client component with a heavy third-party script can produce worse INP than a well-optimised WordPress theme
  • CLS (Cumulative Layout Shift) — still requires explicit width and height on images and reserving space for lazy-loaded embeds

For a broader look at Core Web Vitals targets and measurement, the Core Web Vitals guide covers LCP, INP, and CLS thresholds and how to audit them in PageSpeed Insights and Chrome DevTools.

Caching and Headers

Vercel sets Cache-Control: public, max-age=0, must-revalidate on ISR pages by default — the CDN serves pages from the edge cache but invalidates the entry on each background regeneration. For pages using time-based ISR, Vercel automatically manages stale-while-revalidate behaviour without additional configuration.

If you’re self-hosting on a VPS rather than Vercel, configure your CDN (Cloudflare, Fastly, or nginx proxy cache) to respect s-maxage from the Next.js response and bypass cache on the x-prerender-revalidate header that on-demand revalidation sends. Self-hosted ISR requires more CDN configuration than the managed Vercel path.

Bundle Size and Code Splitting

Next.js automatically code-splits at the page level, but client components that import large libraries ship those libraries to the browser. Two patterns that keep bundles small on a headless WordPress site:

  • Use Server Components for content rendering — components that only display WordPress HTML don’t need to be client components and don’t ship JavaScript at all
  • Dynamic imports for interactive widgets — if a page has a comments section, search modal, or newsletter form, use import dynamic from 'next/dynamic' so those components load only when needed rather than on initial page load

Run next build and check the route analysis output — Next.js prints the JavaScript size for each route. Any route over 100kB of client JavaScript warrants investigation; pages that are mostly WordPress content should be well under 50kB. The WordPress performance guide covers how to get the WordPress backend itself responding quickly to Next.js API requests — slow REST API responses affect build times and on-demand revalidation latency even when the frontend is optimised.

Font Loading with next/font

Custom fonts are a common source of CLS (layout shift when fonts swap) and LCP delay. next/font downloads Google Fonts at build time and serves them from the same domain — no third-party font request, no FOUT (flash of unstyled text), and font-display: optional by default prevents layout shift. For a headless WordPress site, this replaces the Google Fonts link tag that most WordPress themes add to the document head.

Performance Checklist

  1. Choose ISR or on-demand revalidation over SSG for any site that publishes more than once a week
  2. Add images.remotePatterns in next.config.js for your WordPress media domain
  3. Add the priority prop on above-the-fold featured images to improve LCP
  4. Set correct width, height, and sizes on all next/image instances
  5. Use _fields on all REST API requests to reduce payload by 60–70%
  6. Prefer Server Components (App Router) for content-only pages — zero client JS shipped
  7. Use dynamic imports for interactive widgets not needed on initial load
  8. Replace Google Fonts link tags with next/font
  9. Run next build and investigate any route exceeding 100kB of client JavaScript
  10. Measure Core Web Vitals in PageSpeed Insights against real traffic — synthetic scores don’t capture INP regressions from client-side interactions

For protected routes — member dashboards or content behind a login — the JWT authentication guide covers how Next.js handles tokens for requests to the WordPress backend without exposing credentials to the browser. For the SEO implications of the headless setup — including how to ensure crawlers see the same content as your visitors — the headless WordPress SEO guide is the companion read.

Frequently asked questions

The architecture creates the conditions for high performance — content is pre-rendered to static HTML and served from a CDN edge node, eliminating PHP execution from the critical path — but it doesn't guarantee it. A headless site with unoptimised images, a large client-side JavaScript bundle, and no ISR can score worse on Core Web Vitals than a well-optimised traditional WordPress site. The performance gains come from specific implementation choices: rendering strategy (SSG/ISR vs server-side), next/image with correct priority and sizes attributes, Server Components to reduce client JS, and fetch request optimisation with _fields.

On-demand revalidation is the better choice for most production headless WordPress sites. Time-based ISR (revalidate: 3600) leaves pages up to an hour stale after a publish — acceptable for a personal blog but not for a news site or a store where inventory changes. On-demand revalidation, triggered by a WordPress save_post webhook, makes the page fresh within seconds and only regenerates the specific page that changed, not the whole site. The main trade-off is the initial setup cost: you need a revalidation API endpoint on Next.js and a save_post hook in WordPress. For sites that rarely publish, time-based ISR is simpler and sufficient.

Two steps: first, add your WordPress backend domain to images.remotePatterns in next.config.js — without this, Next.js refuses to proxy the image and throws a configuration error at build time. Second, use the Image component from next/image instead of a standard img tag, passing the full image URL from the WordPress REST API's source_url field. Always set explicit width and height to prevent CLS, use the sizes prop to avoid downloading oversized images on mobile viewports (e.g. sizes='(max-width: 768px) 100vw, 800px'), and add the priority prop on the featured image if it appears above the fold — lazy loading the hero image is the most common cause of poor LCP scores on headless WordPress sites.

Both can fetch WordPress content and serve it as static HTML with ISR, but the App Router has a meaningful advantage: page components are React Server Components by default, which render on the server and ship zero JavaScript to the browser for the component itself. A post page that renders WordPress content via dangerouslySetInnerHTML sends no client-side bundle for that component — only the HTML string. For content-heavy headless WordPress sites with minimal interactivity, this can significantly reduce Total Blocking Time and improve INP scores. The Pages Router ships all page components as client-side JavaScript, which adds to bundle size even for components that do no client-side work.

The _fields query parameter on the WordPress REST API limits the response to only the fields you specify, cutting payload size by 60-70% compared to a full post object. A full /wp/v2/posts response includes ~30 fields: raw content, rendered content, protected status, sticky flag, author, categories, tags, embedded data, and metadata. For a post listing page, _fields=id,title,slug,excerpt,date is usually sufficient — there's no reason to fetch full rendered content for a card layout. Smaller payloads reduce build time (Next.js makes fewer kilobytes of network requests during generateStaticParams), reduce on-demand revalidation latency, and reduce memory usage during the build process.

Three to audit specifically: LCP (Largest Contentful Paint) — check whether the above-the-fold featured image has the priority prop set on next/image; lazy loading the hero image is the most common LCP regression in headless builds. CLS (Cumulative Layout Shift) — verify that all next/image instances have explicit width and height props, and that any lazy-loaded embeds (YouTube, Loom, maps) have reserved space via aspect-ratio CSS. INP (Interaction to Next Paint) — profile client components in Chrome DevTools; a component that imports a large library (date picker, rich text editor) and is included on every page will block the main thread on interaction, potentially worse than a traditional WordPress theme. Use PageSpeed Insights with a real URL, not just Lighthouse in DevTools — field data (CrUX) captures real user behaviour that lab scores miss.

Photo of Ajay Khandal

Written by Ajay Khandal

I'm a freelance WordPress developer with 14+ years of experience building, fixing, and speeding up sites for businesses, agencies, and store owners across the US, UK, Europe, and Australia. I specialize in custom themes, WooCommerce, and performance — the kind of work that shows up as faster load times and fewer support tickets. No account managers, no outsourced tickets — you work directly with me, with replies typically inside 5 hours.

Work with me →