A Repeater inside a Flexible Content layout is the canonical two-level nesting pattern in ACF. A "stat row" layout containing a Repeater of individual stats; a "testimonials" layout containing a Repeater of quotes; a "pricing" layout containing a Repeater of pricing tiers. The pattern is fine at two levels and starts to bite at three. Here is the registration, the nested loop, the common bugs, and the line where the structure becomes the wrong choice.
Jump to:
- The use case
- Registering the Repeater inside a Flexible Content layout
- The nested loop pattern
- Common bugs
- Reading the data programmatically
- Writing the data programmatically
- When this pattern is the wrong choice
The use case
The motivating example: a "page builder" Flexible Content field on every page-type post, with a "stat row" layout that contains:
- A
headingtext sub-field (the section heading). - A
statsRepeater sub-field (the actual stat tiles). - Each
statsrow has its own sub-fields:label,value,unit.
The editor adds a stat-row section, names it, then adds however many stat tiles they need. The same field group can contain many other layouts (hero, CTA, testimonials, pricing) with their own internal structure.
This is the agency-scale Flexible Content pattern in practice. The Repeater inside the Flexible Content is what makes individual layouts editorially flexible without exploding into separate Flexible Content layouts per stat.
Registering the Repeater inside a Flexible Content layout
acf_add_local_field_group( [
'key' => 'group_page_builder',
'title' => 'Page Builder',
'fields' => [
[
'key' => 'field_page_builder',
'name' => 'page_builder',
'type' => 'flexible_content',
'layouts' => [
'layout_stat_row' => [
'key' => 'layout_stat_row',
'name' => 'stat_row',
'label' => 'Stat row',
'display' => 'block',
'sub_fields' => [
[
'key' => 'field_stat_row_heading',
'name' => 'heading',
'type' => 'text',
],
[
'key' => 'field_stat_row_stats',
'name' => 'stats',
'type' => 'repeater',
'min' => 1,
'max' => 8,
'sub_fields' => [
[
'key' => 'field_stat_label',
'name' => 'label',
'type' => 'text',
],
[
'key' => 'field_stat_value',
'name' => 'value',
'type' => 'text',
],
[
'key' => 'field_stat_unit',
'name' => 'unit',
'type' => 'text',
],
],
],
],
],
// ... other layouts: hero, cta, testimonials, etc.
],
],
],
'location' => [ /* page locations */ ],
] );The Repeater is registered as a sub-field of the Flexible Content layout. Each Repeater sub-field has its own sub-sub-fields (the columns of each Repeater row).
Key naming convention I use: every key is prefixed by its position in the hierarchy. field_stat_row_stats is the Repeater field inside the stat_row layout. field_stat_label is the label sub-sub-field. The prefix makes the key globally unique and makes debugging easier when reading raw wp_postmeta rows.
The nested loop pattern
The render in the layout's template partial:
<?php
// template-parts/flexible-content/stat-row.php
$heading = get_sub_field( 'heading' );
?>
<section class="fc-stat-row py-16">
<?php if ( $heading ) : ?>
<h2 class="text-3xl font-bold text-center mb-12">
<?php echo esc_html( $heading ); ?>
</h2>
<?php endif; ?>
<?php if ( have_rows( 'stats' ) ) : ?>
<div class="grid grid-cols-2 md:grid-cols-4 gap-8">
<?php while ( have_rows( 'stats' ) ) : the_row(); ?>
<?php
$label = get_sub_field( 'label' );
$value = get_sub_field( 'value' );
$unit = get_sub_field( 'unit' );
?>
<div class="text-center">
<div class="text-4xl font-bold">
<?php echo esc_html( $value ); ?><?php echo esc_html( $unit ); ?>
</div>
<div class="text-sm text-slate-600 mt-2">
<?php echo esc_html( $label ); ?>
</div>
</div>
<?php endwhile; ?>
</div>
<?php endif; ?>
</section>The outer Flexible Content loop is in the dispatcher template (page.php). When the dispatcher matches stat_row, it includes template-parts/flexible-content/stat-row.php. Inside that partial, the row context for the Flexible Content layout is active (so get_sub_field( 'heading' ) reads from the layout's heading sub-field). The inner have_rows( 'stats' ) opens the Repeater loop; each the_row() advances the inner pointer; get_sub_field( 'label' ) inside the inner loop reads from the current Repeater row.
The full dispatcher pattern is in A Cleaner Way to Render ACF Flexible Content Layouts Using Template Parts.
Common bugs
Forgetting the_row() on the inner loop. Without it, the inner get_sub_field calls return nothing. Symptom: the loop iterates the right number of times but every row's data is empty. Covered in Why Your First ACF Repeater Row Appears Empty.
Calling the_row() on the wrong field name. the_row() (no args) advances the innermost active loop. If you accidentally call the_row( 'page_builder' ) (with the outer field name) inside the inner loop, you advance the outer loop instead.
Using get_field instead of get_sub_field. get_field( 'label' ) looks for a top-level field on the post called label. get_sub_field( 'label' ) reads from the current active Repeater row. Easy typo, breaks silently.
Calling get_sub_field outside both loops. A get_sub_field call after the while blocks close returns nothing. There is no active row context.
Stale outer context after the inner loop. After the inner while ( have_rows( 'stats' ) ) { ... } block closes, the row context returns to the outer layout. get_sub_field( 'heading' ) after the inner loop reads the outer layout's heading sub-field again, which is usually what you want, but worth being explicit about.
Reading the data programmatically
Outside the template-render context (in a WP-CLI script, REST callback, or acf/save_post hook), the data is an array of arrays of arrays:
$page_builder = get_field( 'page_builder', $post_id );
// $page_builder === [
// [
// 'acf_fc_layout' => 'stat_row',
// 'heading' => 'Our impact',
// 'stats' => [
// ['label' => 'Sites shipped', 'value' => '120', 'unit' => '+'],
// ['label' => 'Years', 'value' => '15', 'unit' => ''],
// ],
// ],
// // ... other layouts
// ]
foreach ( $page_builder as $row ) {
if ( $row['acf_fc_layout'] === 'stat_row' ) {
$heading = $row['heading'];
foreach ( $row['stats'] as $stat ) {
$label = $stat['label'];
// ...
}
}
}This is the read-only inspection pattern. Useful for analytics, migrations, audits.
Writing the data programmatically
For update_field, the same nested array shape applies:
$page_builder = [
[
'acf_fc_layout' => 'stat_row',
'heading' => 'Our impact',
'stats' => [
['label' => 'Sites shipped', 'value' => '120', 'unit' => '+'],
['label' => 'Years', 'value' => '15', 'unit' => ''],
],
],
];
update_field( 'page_builder', $page_builder, $post_id );The acf_fc_layout key on each Flexible Content row tells ACF which layout's sub-fields to expect inside. The nested stats array is the Repeater's rows. The whole structure persists in one call. See How to Update ACF Fields Programmatically for the full write-side reference.
When this pattern is the wrong choice
The two-level nesting (Repeater inside Flexible Content layout) is fine. Going deeper (Flexible Content layout containing a Repeater whose rows contain another Flexible Content) is the point at which the architecture becomes painful.
Indicators that the Repeater-inside-Flexible-Content is the wrong abstraction:
- The Repeater rows themselves need to be different "types" with different sub-fields. (Use multiple Flexible Content layouts instead, not a Repeater with a "type" field.)
- The Repeater rows are getting queried independently. (Use a separate post type with Relationship instead.)
- The Repeater is regularly getting 50+ rows. (Consider a separate post type or a custom table.)
- Editors are getting lost in the nested editing UI.
For more on the deeper-nesting trap, see Why Deeply Nested ACF Flexible Content Layouts Become a Maintenance Nightmare. The Repeater-inside-Flexible-Content pattern at two levels is the canonical agency pattern; just stop there.
Sources
Authoritative references this article was fact-checked against.
- Flexible Content field (Advanced Custom Fields docs)advancedcustomfields.com
- Repeater field (Advanced Custom Fields docs)advancedcustomfields.com





