
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.
- Install and activate WPGraphQL and WPGraphQL for ACF from the plugin directory.
- Open the Page Sections field group, turn on Show in GraphQL, and set the GraphQL type name to
PageSections. - Open the Sections field and set its GraphQL field name to
sections. - 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:
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 forsrcSetandmediaDetailsgives the frontend the same sizes, width and height thatwp_get_attachment_image()printed in PHP. - Links come back as
urlandtitle, 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:
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.
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. srcSetis the list of sizes WordPress generated, so phones still download the smallest file that looks sharp.fetchPriorityis 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:
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_postat priority 20 runs after ACF has saved the fields, so the frontend fetches the new content, not the old.'blocking' => falsesends 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_URLandWPTC_REVALIDATE_SECRETinwp-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
nullwhen 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