headless wordpress and a coffee

The Sections field from Fast Pages from ACF Flexible Content doesn’t have to end in PHP templates. The same layouts can feed a JavaScript frontend: WordPress stays the editing screen, and a framework such as Next.js or Astro renders the pages.

The structure carries over almost one-to-one. Each layout becomes a component instead of a template file, and the list of supported layouts becomes a component map. Editors notice nothing: they still add, arrange and fill the same sections. Read the PHP version first if you haven’t, since this recipe builds on its fields.

Expose the fields to GraphQL

WPGraphQL turns WordPress into a GraphQL API, and WPGraphQL for ACF adds your fields to it. GraphQL suits Flexible Content because one query can ask for every layout’s fields at once.

  1. Install and activate WPGraphQL and WPGraphQL for ACF from the plugin directory.
  2. Open the Page Sections field group, turn on Show in GraphQL, and set the GraphQL type name to PageSections.
  3. Open the Sections field and set its GraphQL field name to sections.
  4. Visit GraphQL → GraphiQL IDE in the admin and check that page { pageSections { sections } } is available.

Query the sections

Flexible Content comes through as a list where each item can be any of the layouts. The __typename field says which one it is, and that’s the headless version of acf_fc_layout. Each ... on block asks for that layout’s own fields:

lib/get-sections.jsJavaScript
const QUERY = `
  query PageSections( $uri: ID! ) {
    page( id: $uri, idType: URI ) {
      pageSections {
        sections {
          __typename
          ... on PageSectionsSectionsHeroLayout {
            heading
            subheading
            image {
              node {
                sourceUrl
                srcSet
                mediaDetails {
                  width
                  height
                }
              }
            }
            link {
              url
              title
            }
          }
          ... on PageSectionsSectionsTextLayout {
            heading
            body
          }
          ... on PageSectionsSectionsCtaLayout {
            heading
            link {
              url
              title
            }
          }
        }
      }
    }
  }
`;

export async function getSections( uri ) {
    const response = await fetch( process.env.WORDPRESS_GRAPHQL_URL, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify( { query: QUERY, variables: { uri } } ),
    } );

    if ( ! response.ok ) {
        return [];
    }

    const { data } = await response.json();

    return data?.page?.pageSections?.sections ?? [];
}
  • Images come back as a connection, so the media item is inside image.node. Asking for srcSet and mediaDetails gives the frontend the same sizes, width and height that wp_get_attachment_image() printed in PHP.
  • Links come back as url and title, the same as the Link field’s array.
  • A failed request returns an empty list, so the page renders without sections instead of crashing.

Map layouts to components

The component map does the job wptc_section_layouts() did in PHP. It lists the layouts the frontend supports, and anything not on it renders nothing:

components/Sections.jsxJavaScript
import Hero from './sections/Hero';
import Text from './sections/Text';
import Cta from './sections/Cta';

const SECTIONS = {
    PageSectionsSectionsHeroLayout: Hero,
    PageSectionsSectionsTextLayout: Text,
    PageSectionsSectionsCtaLayout: Cta,
};

export default function Sections( { sections } ) {
    return sections.map( ( section, index ) => {
        const Section = SECTIONS[ section.__typename ];

        if ( ! Section ) {
            return null;
        }

        return <Section key={ index } { ...section } position={ index } />;
    } );
}

Each component gets the row’s fields as props, plus its position, exactly as each PHP template got $args.

What carries over from the PHP version

PHP recipe Headless
template-parts/sections/hero.php components/sections/Hero.jsx
wptc_section_layouts() The SECTIONS component map
acf_fc_layout __typename
Early return when content is missing The component returns null
esc_html() on every value React escapes text by default
Named choices that print classes The same choices, passed as props that become classes

The hero component

The hero is still the page’s LCP element, so it keeps the same rule: high priority when it opens the page, lazy-loaded when an editor places it lower.

components/sections/Hero.jsxJavaScript
export default function Hero( { heading, subheading, image, link, position } ) {
    const media = image?.node;

    if ( ! heading || ! media ) {
        return null;
    }

    const isFirst = position === 0;

    return (
        <section className="section section--hero">
            <img
                src={ media.sourceUrl }
                srcSet={ media.srcSet }
                sizes="100vw"
                width={ media.mediaDetails?.width }
                height={ media.mediaDetails?.height }
                alt=""
                fetchPriority={ isFirst ? 'high' : 'auto' }
                loading={ isFirst ? 'eager' : 'lazy' }
                decoding={ isFirst ? 'auto' : 'async' }
            />

            <div className="section__content">
                <h1>{ heading }</h1>

                { subheading && <p>{ subheading }</p> }

                { link?.url && (
                    <a className="button" href={ link.url }>
                        { link.title || 'Learn more' }
                    </a>
                ) }
            </div>
        </section>
    );
}
  • Width and height come from mediaDetails, so the browser reserves the space and nothing shifts.
  • srcSet is the list of sizes WordPress generated, so phones still download the smallest file that looks sharp.
  • fetchPriority is written in camelCase, the way React expects it. When React 19 renders on the server, it also adds a preload link for a high-priority image like this one on its own.

Keep pages fresh

A headless frontend usually caches or pre-builds pages, so it needs to know when an editor saves. This hook asks the frontend to rebuild a page right after ACF saves its fields:

functions.phpPHP
add_action( 'acf/save_post', function ( $post_id ) {
    if ( wp_is_post_revision( $post_id ) || 'page' !== get_post_type( $post_id ) ) {
        return;
    }

    wp_remote_post(
        WPTC_FRONTEND_URL . '/api/revalidate',
        array(
            'blocking' => false,
            'headers'  => array( 'X-Revalidate-Secret' => WPTC_REVALIDATE_SECRET ),
            'body'     => array( 'uri' => wp_make_link_relative( get_permalink( $post_id ) ) ),
        )
    );
}, 20 );
  • acf/save_post at priority 20 runs after ACF has saved the fields, so the frontend fetches the new content, not the old.
  • 'blocking' => false sends the request without making the editor wait for the frontend to answer.
  • The secret header lets the frontend’s revalidate route ignore requests that don’t come from your site. Define WPTC_FRONTEND_URL and WPTC_REVALIDATE_SECRET in wp-config.php, not in the theme.

How the frontend rebuilds the page depends on the framework. In Next.js it’s a route handler that calls revalidatePath(); in a static build it’s a deploy hook.

When headless is worth it

Headless keeps every benefit of the layout structure and changes where the work happens. It’s a real gain for some projects and extra weight for others.

You gain You take on
Pages served from a CDN, with WordPress off the request path Two systems to host, deploy and monitor
The same content feeding a website, an app or several sites Preview of drafts, which needs authenticated queries
A frontend team working in the framework they already know Plugin features that print into the theme, such as SEO tags and forms, rebuilt on the frontend
Interactive sections that ship JavaScript only where they’re used A cache-and-rebuild step every time an editor saves

If a site is one marketing website with no app and no separate frontend team, the PHP version is usually faster to build, cheaper to run, and just as fast to load.

Before you ship

  • The field group shows in GraphQL, and the layout type names in the query match GraphiQL.
  • Every layout has a component and an entry in the component map.
  • Each component returns null when its required content is missing.
  • The first hero loads at high priority with width, height and srcSet; later images are lazy-loaded.
  • Saving a page in WordPress updates it on the frontend.
  • SEO tags, previews and forms are handled on the frontend, or knowingly left out.

Change the renderer, keep the recipe. The house rule