TechEarl

ACF Flexible Content: Template Parts, Layouts and Nested Repeaters

Build an ACF Flexible Content dispatcher with template parts, nested repeater fields, escaped output and programmatic values. Fix blank output and loop errors.

Ishan Karunaratne⏱️ 5 min readUpdated
Share thisCopied
Clean template-parts pattern for ACF Flexible Content. One partial per layout, one dispatcher loop, zero switch statements. Directory structure included.

An ACF Flexible Content dispatcher needs three things: have_rows(), the_row() to advance the active row, and get_row_layout() to select its renderer. I keep the rendering in template parts and explicitly map known layout names to files.

This is a classic PHP-theme pattern using ACF PRO's Flexible Content and Repeater fields. It is useful for a bounded set of editorial sections; it is not a requirement to build another page builder inside every page.

The directory structure

text
your-theme/
  page.php
  template-parts/
    flexible-content/
      hero.php
      stat-row.php
      _partials/
        stat-card.php
        button.php

The registered layout name can use underscores while the filename uses hyphens. An explicit map makes that relationship visible and avoids treating arbitrary field data as a file path.

The dispatcher loop

Put this where the page's sections should render, after the theme has established the intended post:

php
$renderers = array(
    'hero'     => 'template-parts/flexible-content/hero',
    'stat_row' => 'template-parts/flexible-content/stat-row',
);
$post_id = get_the_ID();
if ( function_exists( 'have_rows' ) && have_rows( 'page_builder', $post_id ) ) {
    while ( have_rows( 'page_builder', $post_id ) ) {
        the_row();
        $layout = get_row_layout();
        if ( isset( $renderers[ $layout ] ) ) {
            get_template_part( $renderers[ $layout ], null, array(
                'post_id' => $post_id,
            ) );
        } elseif ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
            error_log( 'Unknown ACF layout: ' . sanitize_key( (string) $layout ) );
        }
    }
}

If this runs in a footer, REST callback or secondary loop, pass the intended post ID instead of assuming the global post represents the page you want. Missing the_row() can produce an infinite loop: have_rows() checks for a next row but does not advance it.

A representative layout partial

template-parts/flexible-content/hero.php reads the active layout's subfields:

php
<?php
$heading = (string) get_sub_field( 'heading' );
$text    = (string) get_sub_field( 'text' );
$url     = (string) get_sub_field( 'cta_url' );
$label   = (string) get_sub_field( 'cta_label' );
?>
<section class="fc-hero">
    <?php if ( '' !== $heading ) : ?>
        <h2><?php echo esc_html( $heading ); ?></h2>
    <?php endif; ?>
    <p><?php echo esc_html( $text ); ?></p>
    <?php if ( $url && $label ) : ?>
        <a href="<?php echo esc_url( $url ); ?>"><?php echo esc_html( $label ); ?></a>
    <?php endif; ?>
</section>

The dispatcher and partial share ACF's active loop state. That does not mean ordinary caller-local PHP variables automatically appear inside the template part. Use $args for explicit inputs; WordPress has supported the third get_template_part() argument since 5.5.

Passing extra data into a partial

A shared button can be independent of ACF:

php
get_template_part( 'template-parts/flexible-content/_partials/button', null, array(
    'url'     => (string) get_sub_field( 'cta_url' ),
    'label'   => (string) get_sub_field( 'cta_label' ),
    'variant' => 'primary',
) );

In _partials/button.php:

php
<?php
$url = (string) ( $args['url'] ?? '' );
$label = (string) ( $args['label'] ?? '' );
$variant = in_array( $args['variant'] ?? '', array( 'primary', 'secondary' ), true )
    ? $args['variant'] : 'primary';
if ( $url && $label ) : ?>
    <a class="btn btn--<?php echo esc_attr( $variant ); ?>"
       href="<?php echo esc_url( $url ); ?>"><?php echo esc_html( $label ); ?></a>
<?php endif;

Allowlist component variants instead of letting data select arbitrary markup or templates. Keep the fields as component inputs; the reusable renderer should not need to know how every other page stores its content.

Nested repeater worked example

Here is a complete field definition for a stat_row layout with a heading and a nested stats repeater. Register it in a site plugin on acf/init; use unique keys across the project. This example adds the group to pages:

php
add_action( 'acf/init', function () {
    if ( ! function_exists( 'acf_add_local_field_group' ) ) {
        return;
    }
    acf_add_local_field_group( array(
        'key' => 'group_te_page_builder',
        'title' => 'Page sections',
        'fields' => array(
            array(
                'key' => 'field_te_page_builder',
                'label' => 'Sections',
                'name' => 'page_builder',
                'type' => 'flexible_content',
                'layouts' => array(
                    'layout_te_stat_row' => array(
                        'key' => 'layout_te_stat_row',
                        'name' => 'stat_row',
                        'label' => 'Statistics',
                        'display' => 'block',
                        'sub_fields' => array(
                            array(
                                'key' => 'field_te_stat_heading',
                                'label' => 'Heading', 'name' => 'heading', 'type' => 'text',
                            ),
                            array(
                                'key' => 'field_te_stats',
                                'label' => 'Statistics', 'name' => 'stats', 'type' => 'repeater',
                                'sub_fields' => array(
                                    array(
                                        'key' => 'field_te_stat_label',
                                        'label' => 'Label', 'name' => 'label', 'type' => 'text',
                                    ),
                                    array(
                                        'key' => 'field_te_stat_value',
                                        'label' => 'Value', 'name' => 'value', 'type' => 'text',
                                    ),
                                    array(
                                        'key' => 'field_te_stat_unit',
                                        'label' => 'Unit', 'name' => 'unit', 'type' => 'text',
                                    ),
                                ),
                            ),
                        ),
                    ),
                ),
            ),
        ),
        'location' => array( array( array(
            'param' => 'post_type', 'operator' => '==', 'value' => 'page',
        ) ) ),
    ) );
} );

The earlier hero partial assumes a separately registered hero layout with the named text/URL fields. The complete definition above is enough to exercise the statistics path without inventing additional fields.

In stat-row.php, save the outer heading before entering the inner repeater:

php
<?php $heading = (string) get_sub_field( 'heading' ); ?>
<section class="fc-stat-row">
    <h2><?php echo esc_html( $heading ); ?></h2>
    <?php if ( have_rows( 'stats' ) ) : ?>
        <dl>
            <?php while ( have_rows( 'stats' ) ) : the_row(); ?>
                <?php get_template_part(
                    'template-parts/flexible-content/_partials/stat-card', null, array(
                        'label' => (string) get_sub_field( 'label' ),
                        'value' => (string) get_sub_field( 'value' ),
                        'unit'  => (string) get_sub_field( 'unit' ),
                    )
                ); ?>
            <?php endwhile; ?>
        </dl>
    <?php endif; ?>
</section>

_partials/stat-card.php:

php
<dt><?php echo esc_html( (string) ( $args['label'] ?? '' ) ); ?></dt>
<dd><?php echo esc_html( (string) ( $args['value'] ?? '' ) ); ?><?php
    echo esc_html( (string) ( $args['unit'] ?? '' ) );
?></dd>

Inside the inner loop, get_sub_field('label') reads the current statistic. After the inner loop finishes normally, the outer layout is active again. the_row() advances the active loop; passing a field name to it does not select another loop.

Reading and writing the data programmatically

Outside the active-row API, use the returned arrays directly:

php
$sections = get_field( 'page_builder', $post_id );
foreach ( is_array( $sections ) ? $sections : array() as $section ) {
    if ( 'stat_row' !== ( $section['acf_fc_layout'] ?? '' ) ) {
        continue;
    }
    foreach ( $section['stats'] ?? array() as $stat ) {
        printf( '<p>%s: %s%s</p>',
            esc_html( (string) ( $stat['label'] ?? '' ) ),
            esc_html( (string) ( $stat['value'] ?? '' ) ),
            esc_html( (string) ( $stat['unit'] ?? '' ) )
        );
    }
}

A foreach over values is valid, but it does not establish the active context for get_sub_field(). Do not mix those two rendering approaches unintentionally.

For an initial programmatic write, use the field keys from the registration and include acf_fc_layout:

php
$sections = array(
    array(
        'acf_fc_layout' => 'stat_row',
        'field_te_stat_heading' => 'At a glance',
        'field_te_stats' => array(
            array(
                'field_te_stat_label' => 'Projects',
                'field_te_stat_value' => '12',
                'field_te_stat_unit'  => '',
            ),
        ),
    ),
);
update_field( 'field_te_page_builder', $sections, $post_id );

That replaces the whole Flexible Content value. Run it only for an intentional, backed-up update; it is not a merge with existing layouts. Read the value back to verify its shape and render it before using the pattern in a bulk migration.

Read a layout name and choose a renderer

For two or three tiny layouts, a switch (get_row_layout()) inside the active loop is enough. For callback rendering, map fixed layout names to your own functions:

php
$callbacks = array( 'stat_row' => 'te_render_stat_summary' );
if ( have_rows( 'page_builder', $post_id ) ) {
    while ( have_rows( 'page_builder', $post_id ) ) {
        the_row();
        $layout = get_row_layout();
        if ( isset( $callbacks[ $layout ] ) && is_callable( $callbacks[ $layout ] ) ) {
            call_user_func( $callbacks[ $layout ] );
        }
    }
}
function te_render_stat_summary() {
    printf( '<h2>%s</h2>', esc_html( (string) get_sub_field( 'heading' ) ) );
}

For an inventory without rendering, read the field as an array and use array_column($sections, 'acf_fc_layout') after checking it is an array. That reveals stored layout names without advancing the active rendering loop.

Blank output and loop troubleshooting

Check the post ID, field name, saved values and registered layout name first. Then verify every while (have_rows(...)) calls the_row() once per iteration. Confirm the allowlist contains the layout and the template file exists with the expected filename.

Read subfields only in the intended row context. A top-level get_field('label') does not mean the inner repeater's label. After an early break or return, do not assume a nested ACF loop has unwound normally; finish the loop or explicitly restructure the code to render arrays.

Test an empty builder, one statistics row, several rows, an empty nested repeater, two consecutive sections and a deliberately unknown layout. Verify output escaping with text containing < and &. Inspect both markup and PHP logs; a blank page may indicate a fatal error or infinite loop, not missing data.

When to choose a simpler layout or different architecture

If a page needs only a heading and body, ordinary fields are easier to maintain. If repeated items have independent identity, permissions or queries, consider a related post type. For deeply nested structures, measure editor and frontend costs separately before assuming more caching will solve them.

The benefit of template parts is explicit rendering ownership and reusable components. It does not establish that PHP is inherently faster than another framework. Keep that claim tied to measurements of the actual application.

Sources

Authoritative references this article was fact-checked against.

TagsWordPressACFFlexible ContentTemplatesArchitecture

Found this useful? Pass it on.

Copied

Ishan Karunaratne

Systems and Network Architect · Chief Technology Officer

Systems and network architect and Chief Technology Officer with more than two decades designing, building, and running production software, cloud and network architecture, Linux systems, and the bare metal underneath them, and lately working AI into the stack. A US Army veteran who served in Operation Iraqi Freedom. What I write here is drawn from the full arc of that work, across architecture, engineering, and operations, not any single job.

Keep reading

Related posts

AWS IAM policy examples by use case: S3 read-only with prefix, S3 read-write with delete denied, EC2 admin scoped to a region via aws:RequestedRegion, Lambda execute and read env vars but not write, iam:PassRole for service-linked roles, MFA-required via aws:MultiFactorAuthPresent, IP-restricted via aws:SourceIp, VPC-endpoint-only via aws:SourceVpce, tag-based prod-vs-dev isolation via aws:ResourceTag, plus the anatomy of a policy document and IAM Access Analyzer for least-privilege validation.

AWS IAM Policy Examples: S3, EC2, Lambda, and Least-Privilege Patterns

A working library of AWS IAM policy examples: S3 read-only with prefix, EC2 admin scoped to a region, Lambda execute-but-not-write, MFA-required, IP-restricted, VPC-endpoint-only, tag-based prod-vs-dev isolation, and the iam:PassRole pattern. Plus the anatomy of a policy document and how Access Analyzer narrows over-permissive Resource: "*" grants.