acf-coffee-codingFlexible 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:
- Go to ACF → Field Groups and add a group called Page Sections.
- Add one field: label Sections, name
page_sections, type Flexible Content. - Set the button label to Add section, so editors know what the button does.
- 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:
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:
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’spositiontoo, so a template knows whether it opens the page. - A missing file doesn’t break the page.
get_template_part()returnsfalse, 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:
<?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, anddecoding="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:
- Add a
quotelayout to the Sections field, with its own fields. - Add
'quote'towptc_section_layouts(). - Create
template-parts/sections/quote.php, andquote.cssif 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.phpbecomesHero.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:
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.phporhero.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-jsonand 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
namehas changed since content was added.
Give editors room to arrange the page, and no way to publish a broken one. The house rule