TechEarl

Using ACF Like a Lightweight Component System

ACF Flexible Content plus template parts plus a shared component library is effectively a lightweight component system for WordPress. The patterns: one component per layout, props via sub-fields, composition over inheritance, and how it compares to React-based alternatives.

Ishan Karunaratne⏱️ 6 min readUpdated
Share thisCopied
ACF Flexible Content + template parts + a shared library is a lightweight component system for WordPress. Patterns and comparison to React alternatives.

ACF Flexible Content plus template parts plus a shared component library is effectively a lightweight component system for WordPress. Not a JavaScript framework, not React or Vue, just PHP partials with structured input from ACF. The patterns are familiar to anyone who has used component-based UI libraries: one component per layout, props via sub-fields, composition over inheritance, a small set of shared primitives. After years of running this approach on agency sites and directory sites at scale, here is what it looks like in practice and where it lands versus React-based alternatives.

Jump to:

What "component" means in this context

In this article, a "component" is:

  • One Flexible Content layout (or one ACF Block, or one Repeater row template) defining the data shape.
  • One PHP partial rendering that data shape.
  • An editor-facing experience for adding instances and configuring them.

That is the same loose definition that maps to React, Vue, Svelte, etc. The implementation is just PHP and ACF instead of JavaScript and a virtual DOM.

The benefit over hand-coded page templates: the editor can compose pages from a library of components instead of asking the developer to add a new section per request.

The benefit over a visual page builder: full control over the rendered HTML, no plugin lock-in, no React layer to maintain, no JavaScript build step.

The structure: one component per Flexible Content layout

The directory:

code
themes/your-theme/
└── template-parts/
    └── flexible-content/
        ├── hero.php
        ├── cta.php
        ├── stat-row.php
        ├── testimonials.php
        ├── pricing.php
        ├── content-feed.php
        ├── faq.php
        ├── feature-list.php
        └── _partials/
            ├── card.php
            ├── button.php
            └── stat-card.php

Each component (top-level partial) maps 1:1 to a Flexible Content layout in the ACF registration. The dispatcher pattern is in A Cleaner Way to Render ACF Flexible Content Layouts Using Template Parts.

The _partials/ underscore-prefixed subdirectory holds shared primitives that get used from multiple top-level components but are not themselves layouts.

Props: sub-fields are component inputs

Each component reads its props (sub-fields) from the active row context:

php
<?php
// template-parts/flexible-content/hero.php
$heading = get_sub_field( 'heading' );
$subheading = get_sub_field( 'subheading' );
$cta_text = get_sub_field( 'cta_text' );
$cta_url = get_sub_field( 'cta_url' );
$theme = get_sub_field( 'theme' ); // 'light' or 'dark'
?>

<section class="fc-hero fc-hero--<?php echo esc_attr( $theme ?: 'light' ); ?>">
    <h1><?php echo esc_html( $heading ); ?></h1>
    <p><?php echo esc_html( $subheading ); ?></p>
    <?php if ( $cta_text && $cta_url ) : ?>
        <a href="<?php echo esc_url( $cta_url ); ?>" class="btn"><?php echo esc_html( $cta_text ); ?></a>
    <?php endif; ?>
</section>

The sub-fields in the ACF registration are the component's props. The component reads them; the editor configures them. Identical conceptually to a React component receiving props via JSX attributes.

Composition: components inside components

When a component contains other repeating items (a "stat row" containing many "stat cards"), the composition is straightforward:

php
<?php
// template-parts/flexible-content/stat-row.php
$heading = get_sub_field( 'heading' );
?>
<section class="fc-stat-row">
    <h2><?php echo esc_html( $heading ); ?></h2>

    <?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 get_template_part( 'template-parts/flexible-content/_partials/stat-card' ); ?>
            <?php endwhile; ?>
        </div>
    <?php endif; ?>
</section>
php
<?php
// template-parts/flexible-content/_partials/stat-card.php
$label = get_sub_field( 'label' );
$value = get_sub_field( 'value' );
$unit = get_sub_field( 'unit' );
?>
<div class="stat-card">
    <div class="stat-value"><?php echo esc_html( $value ); ?><?php echo esc_html( $unit ); ?></div>
    <div class="stat-label"><?php echo esc_html( $label ); ?></div>
</div>

The parent component (stat-row.php) composes child components (_partials/stat-card.php). The child reads from the active Repeater row's context. This is the WordPress equivalent of {stats.map(stat => <StatCard {...stat} />)} in React.

Shared primitives: the _partials/ directory

The shared primitives are components that are not Flexible Content layouts themselves but are useful inside several layouts:

  • _partials/card.php: a generic card wrapper used by hero, feature list, content feed.
  • _partials/button.php: a button with theme variants used everywhere a CTA appears.
  • _partials/section-heading.php: a section header with eyebrow + heading + subheading, used by most top-level layouts.

These take their input via $args (the WP 5.5+ template-part argument):

php
get_template_part( 'template-parts/flexible-content/_partials/button', null, [
    'text' => $cta_text,
    'url' => $cta_url,
    'variant' => 'primary',
] );
php
<?php
// _partials/button.php
$args = $args ?? [];
$text = $args['text'] ?? '';
$url = $args['url'] ?? '#';
$variant = $args['variant'] ?? 'primary';
?>
<a href="<?php echo esc_url( $url ); ?>" class="btn btn--<?php echo esc_attr( $variant ); ?>">
    <?php echo esc_html( $text ); ?>
</a>

This is the WordPress equivalent of <Button text={cta} url={url} variant="primary" />.

Conditional rendering and variants

A common need: the same component should render differently based on a variant prop. The cleanest pattern uses ACF Select fields for the variant and a single template that branches:

php
<?php
// template-parts/flexible-content/hero.php
$variant = get_sub_field( 'layout_variant' ); // 'centered', 'split', 'full-bleed'
$heading = get_sub_field( 'heading' );
?>

<section class="fc-hero fc-hero--<?php echo esc_attr( $variant ); ?>">
    <?php if ( $variant === 'split' ) : ?>
        <div class="fc-hero__media"><?php /* image */ ?></div>
        <div class="fc-hero__text"><?php /* text */ ?></div>
    <?php else : ?>
        <div class="fc-hero__text"><?php /* text */ ?></div>
    <?php endif; ?>
</section>

The CSS handles most variant differences. The PHP only branches when the structure itself changes.

For very different variants, prefer a separate Flexible Content layout entirely rather than one component with many variants. The line: if the editor would think of it as "a different thing," it should be a different layout.

How this compares to React-based alternatives

Versus React + Headless WordPress:

  • PHP partials are simpler to author and maintain. No build step, no React lifecycle, no hydration concerns.
  • Performance is comparable for content-heavy marketing sites. SSR React with Next.js is faster on cold-cache navigation; ACF + PHP is faster on first byte.
  • SEO is a wash for most sites. Both can produce SEO-clean output.
  • Hiring is much easier for ACF + PHP (vastly larger talent pool of WordPress developers than headless-React-WordPress developers).
  • Maintenance burden over five years is much lower for ACF + PHP. PHP partials do not break across framework versions; React components do.

Versus native Gutenberg custom blocks:

  • ACF + PHP partials win on maintenance. No block API version migrations; no React patterns shifting.
  • Native Gutenberg blocks win on editor experience if the editor team prefers the Gutenberg block UI specifically.
  • For a long-lived agency client portfolio, ACF + PHP is the lower-overhead pick. Covered in detail in Why Many Agencies Still Prefer ACF Over Gutenberg.

Versus visual page builders (Divi, Elementor):

  • The page builder is faster to ship the first project on.
  • The component-system approach is faster to ship the tenth project on if you share the partials across projects.
  • The builder produces more verbose HTML by default; the component approach produces hand-tuned markup.
  • See Divi vs Elementor for Agency Workflows and Why Agencies Still Use Divi for the broader comparison.

When this pattern is the wrong choice

  • You are building a SaaS product, not a marketing site. Use React.
  • The team has zero PHP experience. Use whatever they know.
  • The site is fundamentally a web app, not a content site. Different problem; different tooling.
  • Editor team requires Gutenberg specifically (publishing workflow, plugin compatibility). Use Gutenberg with custom blocks, or use ACF Blocks for the middle path.

For everything that looks like a content-driven marketing or directory site with a small handful of repeatable section types, the ACF-as-component-system approach is the boring choice that scales. For the broader agency stack that this fits into, see The Exact Stack I would Use to Run a Small WordPress Agency Today.

Sources

Authoritative references this article was fact-checked against.

TagsWordPressACFComponentsArchitecture

Found this useful? Pass it on.

Copied

Ishan Karunaratne

Tech Architect · Software Engineer · AI/DevOps

Tech architect and software engineer with 20+ years building software, Linux systems, and DevOps infrastructure, and lately working AI into the stack. Currently Chief Technology Officer at a healthcare tech startup, which is where most of these field notes come from.

Keep reading

Related posts

Using ACF with Divi for Dynamic Content

Divi's Dynamic Content lets editors bind ACF field values to module properties without leaving the visual builder. The setup, the patterns that work for Text and Image fields, the limits for Repeater and Flexible Content, and the custom-shortcode escape hatch.

Turning Figma Designs Into WordPress Components Using AI

The Figma to ACF Flexible Content pipeline used to be the slow part of every agency build. With AI in the loop, you describe the design intent, hand over the Figma JSON or screenshots, and get back working ACF registration plus template partials. Here is the realistic workflow.