ACF field editor next to a cup of coffeeacf-coffee-coding

Flexible Content is the closest ACF gets to a page builder, and it’s easy to end up with a messy one: a 300-line page.php full of if and elseif branches, sections that print empty wrappers when an editor skips a field, and pages that break the day someone renames a layout.

The clean version gives every section type, or layout, its own fields and its own PHP file, with one small loop joining them. Editors arrange the page from a fixed set of sections, and the theme decides how each one looks. This recipe shows how the pieces fit, how to grow it, and why it’s built this way.

Create the flexible content field

You’ll need ACF PRO, since Flexible Content is a PRO field. In the WordPress admin:

  1. Go to ACF → Field Groups and add a group called Page Sections.
  2. Add one field: label Sections, name page_sections, type Flexible Content.
  3. Set the button label to Add section, so editors know what the button does.
  4. Under Location Rules, show the group when Post Type is equal to Page.

Map fields to each layout

Each layout is one kind of section, with only the fields that section needs. Three cover most pages: a hero to open the page, a text section for content, and a call to action.

Layout name Label Fields
hero Hero Heading (Text, required, 60 characters max), Subheading (Text, 140 characters max), Image (Image, required, returns the ID, at least 1920 pixels wide) and Button (Link, returns an array); set the layout’s maximum to 1
text Text Heading (Text, required, 80 characters max) and Body (WYSIWYG, basic toolbar, media upload off)
cta Call to action Heading (Text, required, 60 characters max) and Button (Link, required, returns an array); set the layout’s maximum to 1

The settings in that table do the protective work. required stops a section being saved without its main content, character limits keep headings from wrapping into the design, and the basic toolbar keeps font colors and pasted tables out of text sections.

The layout name is the important part: it’s what the theme uses to find the PHP file. Editors only ever see the label.

Give each layout a PHP file

Every layout gets a template named after it, and, if it needs any, a stylesheet with the same name:

theme structureTEXT
your-theme/
├── functions.php
├── page.php
├── template-parts/
│   └── sections/
│       ├── hero.php
│       ├── text.php
│       └── cta.php
└── assets/
    └── css/
        └── sections/
            ├── hero.css
            ├── text.css
            └── cta.css

One loop reads the sections and hands each row to its file. get_field() returns every row with its layout name in acf_fc_layout plus its own fields, so the loop only has to match the name to a template:

functions.phpPHP
function wptc_section_layouts() {
    return array( 'hero', 'text', 'cta' );
}

function wptc_render_sections( $post_id = null ) {
    $sections = get_field( 'page_sections', $post_id );

    if ( empty( $sections ) || ! is_array( $sections ) ) {
        return;
    }

    foreach ( $sections as $index => $section ) {
        $layout = $section['acf_fc_layout'] ?? '';

        if ( ! in_array( $layout, wptc_section_layouts(), true ) ) {
            continue;
        }

        $section['position'] = $index;

        $found = get_template_part( 'template-parts/sections/' . $layout, null, $section );

        if ( false === $found && current_user_can( 'edit_posts' ) ) {
            printf( "<!-- Missing section template: %s.php -->\n", esc_html( $layout ) );
        }
    }
}

Call wptc_render_sections() in page.php where the sections should appear.

  • wptc_section_layouts() is the single list of layouts the theme supports. Anything not on it never renders.
  • The whole row is passed as $args (WordPress 5.5 and later), so a template reads $args['heading'] without depending on ACF’s loop. The loop adds the row’s position too, so a template knows whether it opens the page.
  • A missing file doesn’t break the page. get_template_part() returns false, and only logged-in editors see a comment in the source.

Each template does one job: it checks for the content it needs, returns early if that’s missing, and prints its section. Here’s the hero layout:

template-parts/sections/hero.phpPHP
<?php
/**
 * Section: hero.
 *
 * @var array $args This row's fields.
 */
if ( empty( $args['heading'] ) || empty( $args['image'] ) ) {
    return;
}

$is_first = 0 === ( $args['position'] ?? 0 );
$link     = $args['link'] ?? array();
?>
<section class="section section--hero">
    <?php
    echo wp_get_attachment_image(
        $args['image'],
        'full',
        false,
        array(
            'fetchpriority' => $is_first ? 'high' : 'auto',
            'loading'       => $is_first ? 'eager' : 'lazy',
            'decoding'      => $is_first ? 'auto' : 'async',
            'sizes'         => '100vw',
            'alt'           => '',
        )
    );
    ?>

    <div class="section__content">
        <h1><?php echo esc_html( $args['heading'] ); ?></h1>

        <?php if ( ! empty( $args['subheading'] ) ) : ?>
            <p><?php echo esc_html( $args['subheading'] ); ?></p>
        <?php endif; ?>

        <?php if ( ! empty( $link['url'] ) ) : ?>
            <a class="button" href="<?php echo esc_url( $link['url'] ); ?>">
                <?php echo esc_html( ! empty( $link['title'] ) ? $link['title'] : __( 'Learn more', 'wptc' ) ); ?>
            </a>
        <?php endif; ?>
    </div>
</section>

The hero is usually the page’s Largest Contentful Paint element, so its image is the one file that should load first. Because the field returns an attachment ID, wp_get_attachment_image() writes the srcset, width and height, and the template adds the rest:

  • First on the page: fetchpriority="high", no lazy loading, and decoding="auto", so the browser fetches and paints it first.
  • Placed lower by an editor: lazy-loaded like any other image, so it doesn’t compete with what’s actually on the first screen.

Extend it

Adding a section type, say a Quote, takes three changes and touches nothing that already works:

  1. Add a quote layout to the Sections field, with its own fields.
  2. Add 'quote' to wptc_section_layouts().
  3. Create template-parts/sections/quote.php, and quote.css if it needs styles.

Every page can use a Quote section as soon as the file exists, and no existing template changes.

The same pattern covers design options. When editors ask for control over spacing or backgrounds, add a Button Group with named choices, like Small and Large, instead of a free margin field. The template turns the choice into a class such as section--spacing-small, and the stylesheet decides what Small means. Change that one value later and every Small section on the site moves together.

Granular controls, scaled properly

Named choices aren’t a ceiling. Once the structure is in place, you can give editors much finer control with ACF fields, as long as every control still goes through the theme rather than around it.

Build the controls once and share them. Create a Section Settings field group, set it to inactive so it never appears on its own, then add it to each layout with a Clone field set to Seamless, so the settings appear as the layout’s own fields. Every layout gets the same controls, and adding a new one is a single edit. Put them under a Tab field called Settings so the content fields stay first.

Control ACF field What the template prints
Top and bottom spacing Two Button Groups: None, Small, Large Classes such as section--pt-small section--pb-large
Content width Select: Narrow, Default, Wide A class such as section--width-narrow
Background Button Group: Light, Dark, Brand A class such as section--bg-dark
Hero overlay strength Range: 0 to 80, in steps of 10 A CSS variable such as style="--overlay: 0.4"
Anchor for in-page links Text, cleaned with sanitize_title() An id such as id="pricing"

That’s as much control as most page builders offer, without their downside, because four rules hold for every control:

  • The limits live in the field. Choices, or a Range’s minimum, maximum and step, decide what an editor can pick. There’s no box to type 37px into.
  • Every control has a default. Sections saved before the control existed keep working, and look the way they did as long as the default matches the old styling.
  • The output is a class or a CSS variable, never raw CSS. The stylesheet still decides what Small, Wide or Dark means, so a redesign stays a stylesheet change.
  • Everything is escaped on the way out, with esc_attr() for classes, variables and the anchor.

Going headless

The same structure works without PHP templates. If the site’s pages are rendered by a JavaScript frontend such as Next.js or Astro, WordPress stays the editing screen and the Sections field is read through GraphQL instead:

  • Each layout becomes a component, so hero.php becomes Hero.jsx.
  • wptc_section_layouts() becomes a component map, and unknown layouts still render nothing.
  • The early returns, the hero’s loading rules and the named choices all carry over unchanged.

Editors see no difference. ACF Flexible Content, Headless walks through the query, the component map and keeping pages fresh when editors save.

Why it’s built this way

Every piece of the setup answers a problem page builders and long templates run into. The two that matter most over a site’s life are how fast it loads and how easy it is to change.

Performance

A page should load the CSS for the sections it uses, not for every section the theme offers. Because each layout has its own stylesheet, the theme can check which layouts a page uses and enqueue only those:

functions.phpPHP
add_action( 'wp_enqueue_scripts', function () {
    if ( ! is_singular() ) {
        return;
    }

    $sections = get_field( 'page_sections', get_queried_object_id() );

    if ( empty( $sections ) || ! is_array( $sections ) ) {
        return;
    }

    $used = array_intersect( wptc_section_layouts(), wp_list_pluck( $sections, 'acf_fc_layout' ) );

    foreach ( $used as $layout ) {
        $file = 'assets/css/sections/' . $layout . '.css';

        if ( ! file_exists( get_theme_file_path( $file ) ) ) {
            continue;
        }

        wp_enqueue_style(
            'section-' . $layout,
            get_theme_file_uri( $file ),
            array(),
            filemtime( get_theme_file_path( $file ) )
        );
    }
} );

A page with only Hero and Text sections never downloads the CTA styles. Add ten more layouts and pages that don’t use them stay just as light. There’s also no CSS saved per section and no builder script running in the browser: just markup and the CSS it needs.

Maintenance

Most of a theme’s life is spent being changed, not built. This structure keeps those changes small and predictable:

  • One place to look. A bug in the hero is in hero.php or hero.css. Nobody scrolls through a 300-line template to find the branch that prints it.
  • Small, safe changes. Editing one layout can’t affect another, so a change is easy to review and easy to roll back.
  • Field changes show up in review. With acf-json, a new field or a changed limit arrives as a diff in the same commit as the template that uses it.
  • Clean removal. To retire a layout, take it out of wptc_section_layouts() first. Old pages stop showing it at once, without errors, and you can delete the files whenever you like.
  • No builder lock-in. There’s no page-builder plugin to update and no shortcodes left behind in content if you change direction. The saved data is plain fields, and the markup is yours.

What each choice buys you

Choice What it gives you
Per-layout CSS Performance. Pages load styles only for the sections they show.
Required fields and early returns Nothing half-built. A section with missing content prints nothing instead of an empty box.
One list of supported layouts Non-breaking. A layout added to the fields but not yet built never reaches visitors, and a missing file never takes the page down.
Controls that print classes, not CSS Consistency. Editors get fine control, every page still uses the same spacing and colors, and a redesign is a stylesheet change, not a page-by-page edit.
One file per layout Maintenance. Changing a section means opening one small file, and a new section never touches the others.
Fields in acf-json Safe deploys. Field changes are reviewed and shipped with the theme, not clicked together on the live site.

Before you ship

  • The field group is saved to acf-json and committed with the theme.
  • Every layout has a template, an entry in wptc_section_layouts(), and a stylesheet if it needs one.
  • Each template returns early when its required content is missing.
  • Every control has a default and prints a class or CSS variable, never raw CSS or free pixel values.
  • A page with a missing template still loads, with the comment only in an editor’s view of the source.
  • No layout or field name has changed since content was added.

Give editors room to arrange the page, and no way to publish a broken one. The house rule