Landing Pages with ACF Flexible Content

We use the Gutenberg block editor for most content on the sites we build, but sometimes the homepage and landing pages are too complex to manage with Gutenberg.

Here’s the homepage of The Kitchen Magpie, which we launched yesterday:

Modular homepage built with ACF Flexible Content

There are some plugins that add full page-building capabilities to Gutenberg, but our clients prefer a simpler interface and a focus on content creation. The overall layout of the page should be managed by the theme to ensure consistency and effective presentation at all viewports.

We build these modular pages with the Flexible Content field type in Advanced Custom Fields. I also disable the editor on these templates.

Jump to Section

  1. How flexible content works
  2. Accessing the data
  3. Code organization
  4. Modular page template
  5. Using modules in your theme
  6. Category landing pages

How flexible content works

The Flexible Content field in ACF lets you define multiple group of fields, known as layouts. See the ACF documentation for more information. I also refer to them as modules.

Content editors assemble a page by inserting layouts in any order they like and filling in their respective fields.

I start with a simple Content layout, then add more complex layouts as needed. Most sites we build have 6-12 layouts.

When editing a page, the content editor can add, remove, and re-arrange layouts. Layouts can be collapsed for easier organization / reordering, and expanded to edit the fields.

One quick note: You can’t force a modular layout approach on a non-modular design. You should work closely with the designer on your project to ensure they are approaching these pages in a modular way.

We’re able to build these modular landing pages that load fast and look great because we have an extensive design process in place to conceive and implement these modules.

Accessing the data

You can retrieve the data using get_field(), just like any other ACF field. If your flexible content field has a meta_key of ea_modules , use $modules = get_field( 'ea_modules' );

This will give you an array of all the layouts, in order, and the values of the fields within them. For instance, the $modules array on the homepage of The Kitchen Magpie starts out:

Array
(
    [0] => Array
        (
            [acf_fc_layout] => quick_links
            [quick_links] => Array
                (
                    [0] => Array
                        (
                            [title] => Desserts & Sweets
                            [url] => https://kitchenmagpie.com/recipes/desserts/
                            [image] => 34679
                        )

                    [1] => Array
                        (
                            [title] => Main Dishes
                            [url] => https://kitchenmagpie.com/recipes/main-dishes/
                            [image] => 53652
                        )

                    [2] => Array
                        (
                            [title] => Pyrex Glassware
                            [url] => https://kitchenmagpie.com/glassware/
                            [image] => 50075
                        )

...

Each item in the array starts with acf_fc_layout which tells you what layout is being used. It then contains all the fields and sub-fields within that layout.

If you prefer using get_post_meta() to retrieve all metadata, then you would use $modules = get_post_meta( get_the_ID(), 'ea_modules', true ); and receive the following array with just the layouts listed in their order.

Array
(
    [0] => quick_links
    [1] => about
    [2] => post_listing
    [3] => km
    [4] => jump_links
    [5] => post_listing
    [6] => km
    [7] => cookbooks
    [8] => post_listing
    [9] => post_listing
    [10] => save_recipes_cta
    [11] => km
    [12] => post_listing
    [13] => km
    [14] => featured_logos
)

You would then directly call the subfields using {flexible content meta key}_{count}_{field_key}. To access the “title” field in first layout on the page, you would use:

$title = get_post_meta( get_the_ID(), 'ea_modules_0_title', true );

Here’s more information on using ACF with the WP core meta functions. I find it much easier to use get_field() for flexible content fields.

In your theme file, you can use the switch statement to define the markup for each module. Example:

foreach( $modules as $module ) {
	switch( $module['acf_fc_layout'] ) {

		case 'content':
			echo apply_filters( 'the_content', $module['content'] );
			break;

		case 'quick_links':
			// quick links markup goes here
			break;
	}
}

Code organization

Rather than looping through the modules in a page template, I prefer keeping it in a separate file. This makes the code a bit easier to read, and allows you to display the modules outside the content area.

Inside my theme I create /inc/modules.php with the following in it:

<?php
/**
 * Modules
 *
 * @package      ClientName
 * @author       Bill Erickson
 * @since        1.0.0
 * @license      GPL-2.0+
**/

/**
 * Display Modules
 *
 */
function ea_modules( $post_id = false ) {

	if( ! function_exists( 'get_field' ) )
		return;

	$post_id = $post_id ? intval( $post_id ) : get_the_ID();
	$modules = get_field( 'ea_modules', $post_id );
	if( empty( $modules ) )
		return;

	foreach( $modules as $i => $module )
		ea_module( $module, $i );
}

/**
 * Display Module
 *
 */
function ea_module( $module = array(), $i = false ) {
	if( empty( $module['acf_fc_layout'] ) )
		return;

	ea_module_open( $module, $i );

	switch( $module['acf_fc_layout'] ) {

		case ea_module_disable( $module ):
			break;

		case 'content':
			ea_module_header( $module );
			echo '<div class="entry-content">' . apply_filters( 'ea_the_content', $module['content'] ) . '</div>';
			break;

		// More modules go here
	}

	ea_module_close( $module, $i );
}

/**
 * Module Open
 *
 */
function ea_module_open( $module, $i ) {

	if( ea_module_disable( $module ) )
		return;

	$classes = array( 'module' );
	$classes[] = 'type-' . str_replace( '_', '-', $module['acf_fc_layout'] );
	if( !empty( $module['bg_color'] ) )
		$classes[] = 'bg-' . $module['bg_color'];

	$id = !empty( $module['anchor_id'] ) ? sanitize_title_with_dashes( $module['anchor_id'] ) : 'module-' . ( $i + 1 );

	echo '<section class="' . join( ' ', $classes ) . '" id="' . $id . '">';
	echo '<div class="wrap">';

}

/**
 * Module Header
 *
 */
function ea_module_header( $module ) {

	if( !empty( $module['title'] ) ) {
		echo '<header><h3>' . esc_html( $module['title'] ) . '</h3></header>';
	}
}

/**
 * Module Close
 *
 */
function ea_module_close( $module, $i ) {

	if( ea_module_disable( $module ) )
		return;

	echo '</div>';
	echo '</section>';
}

/**
 * Module Disable
 *
 */
function ea_module_disable( $module ) {
	$disable = false;
	if( 'save_recipes_cta' == $module['acf_fc_layout'] && is_user_logged_in() )
		$disable = true;

	return $disable;
}

/**
 * Has Module
 *
 */
function ea_has_module( $module_to_find = '', $post_id = false ) {
	if( ! function_exists( 'get_field' ) )
		return;

	$post_id = $post_id ? intval( $post_id ) : get_the_ID();
	$modules = get_field( 'ea_modules', $post_id );
	$has_module = false;

	foreach( $modules as $module ) {
		if( $module_to_find == $module['acf_fc_layout'] )
			$has_module = true;
	}

	return $has_module;
}

The ea_modules() function displays all the modules, and would be called within the page template file. It returns early if get_field()doesn’t exist (ie: ACF is not active) to prevent errors.

It then retrieves all the modules, loops through them, and calls ea_module( $module, $i ); where $module is the array of module data, and $i is the order (used for ID / anchor links).

The ea_module() function is where the actual module display code goes. It displays the opening and closing markup, which is same for all modules, using ea_module_open() and ea_module_close(). It then uses a switch() statement to display the module-specific content.

I also have a ea_module_disable() function which can be used to conditionally disable a module from display. In this example (line 106-107), the “Save Recipes CTA” module isn’t displayed if a user is logged in.

There’s also a helper function at the end, ea_has_module() , which is useful for modifying other areas of the theme if a certain modules in use. For instance, on a category landing page you could remove the default category intro if the “Header” module is used.

Modular page template

Now that we have the modules functionality built, it’s time to use it on a page. In ACF I set the metabox containing my Flexible Content field to display on the appropriate page template:

Then inside the page template, I call the ea_modules() function to display all the modules. I’m also modifying the attributes of .site-inner and removing the .wrap because each module has its own wrapper (more information).

<?php
/**
 * Template Name: Modules
 *
 * @package      ClientName
 * @author       Bill Erickson
 * @since        1.0.0
 * @license      GPL-2.0+
**/

// Remove 'site-inner' from structural wrap
add_theme_support( 'genesis-structural-wraps', array( 'header', 'footer-widgets', 'footer' ) );

/**
 * Add the attributes from 'entry', since this replaces the main entry
 *
 * @author Bill Erickson
 * @link http://www.billerickson.net/full-width-landing-pages-in-genesis/
 *
 * @param array $attributes Existing attributes.
 * @return array Amended attributes.
 */
function be_site_inner_attr( $attributes ) {

	// Add a class of 'full' for styling this .site-inner differently
	$attributes['class'] .= ' full';

	// Add an id of 'genesis-content' for accessible skip links
	$attributes['id'] = 'genesis-content';

	// Add the attributes from .entry, since this replaces the main entry
	$attributes = wp_parse_args( $attributes, genesis_attributes_entry( array() ) );

	return $attributes;
}
add_filter( 'genesis_attr_site-inner', 'be_site_inner_attr' );


// Build the page
get_header();
ea_modules();
get_footer();

Using modules in your theme

You can easily reuse these modules elsewhere in the site. We have a “Save Recipes CTA” module on The Kitchen Magpie that we also include at the bottom of every post.

In the single.php file I have the following:

/**
 * Save Recipe CTA
 *
 */
function ea_single_save_recipe_cta() {
	ea_module( array( 'acf_fc_layout' => 'save_recipes_cta' ) );
}
add_action( 'genesis_after_entry', 'ea_single_save_recipe_cta', 5 );

The acf_fc_layout is the module you want to display. If the module included any fields that needed to be filled in, you would include those in the array as well (ex: 'title' => 'My Module Title' ).

Category landing pages

We build a lot of websites for food bloggers, and one of the most common requests is improved category archive pages. They want more than a long list of the most recent posts.

They need SEO friendly and relevant content, while also engaging users with popular posts in this category and subcategory listings.

Category archive landing page – modules appear above recent posts

You can easily add an ACF metabox to the Edit Category screen, but don’t do it for flexible content fields like this. The Edit Category screen doesn’t have much room for metaboxes, so content editing will be difficult.

Storing a landing page’s worth of content as term meta could lead to performance issues. If you list the categories anywhere on a single post, you’d also load all the landing page content for each category since the term metadata is also pulled from the database.

In my core functionality plugin I create an “Archive Landing” post type for building category and tag landing pages. This is similar to my recent post on block-based widget areas, except we’re using ACF Flexible Content instead of the Gutenberg block editor.

To create a new landing page, a content editor goes to Archive Landing > Add New, gives it a title, and adds modules in the flexible content field.

Create a separate metabox (“Appears on”) for connecting the archive landing page to a taxonomy:

  • If you are only supporting the category taxonomy, create a taxonomy field with a key of ea_connected_category
  • If you are supporting multiple taxonomies, create a select field with a key of ea_connected_taxonomy for selecting the taxonomy, and taxonomy fields for each supported taxonomy (ea_connected_{taxonomy}). Use conditional logic to only show the taxonomy field for the selected taxonomy. Also, don’t forget to edit EA_Archive_Landing to add your additional taxonomies to the $supported_taxonomies array.

The taxonomy field will update the terms on the current post when saved. You should also use the ACF options to hide the standard taxonomy metaboxes on this page.

In my archive.php file I do a tax_query for posts in the archive_landing post type that are attached to the current category/tag. If a landing page is found, its modules are displayed at the top of the page, above the recent posts in this category. I’m also using the ea_has_module() mentioned above to remove the standard archive title if the Header module is used, which contains its own h1.

You can use ea_archive_landing()->get_archive_id(); to get the post ID for the archive landing page connected to the current category/tag/term. It handles the logic of ensuring it’s a supported taxonomy and doing a tax_query for the connected post.

<?php
/**
 * Archive
 *
 * @package      ClientName
 * @author       Bill Erickson
 * @since        1.0.0
 * @license      GPL-2.0+
**/
/**
 * Archive landing modules
 *
 */
function ea_archive_landing_modules() {
	if( ! function_exists( 'ea_archive_landing' ) )
		return;
	
	if( get_query_var( 'paged' ) )
		return;

	$archive_landing_id = ea_archive_landing()->get_archive_id();
	if( empty( $archive_landing_id ) )
		return;

	ea_modules( $archive_landing_id );

	// Only remove archive header if the 'header' block is used
	if( ea_has_module( 'header', $archive_landing_id ) )
		remove_action( 'genesis_before_loop', 'genesis_do_taxonomy_title_description', 15 );
}
add_action( 'genesis_after_header', 'ea_archive_landing_modules' );
genesis();
<?php
/**
 * Archive Landing
 *
 * @package      CoreFunctionality
 * @author       Bill Erickson
 * @since        1.0.0
 * @license      GPL-2.0+
**/

class EA_Archive_Landing {

	/**
	 * Instance of the class.
	 * @var object
	 */
	private static $instance;

	/**
	 * Supported taxonomies
	 * @var array
	 */
	public $supported_taxonomies = array( 'category' );

	/**
	 * Class Instance.
	 * @return EA_Archive_Landing
	 */
	public static function instance() {
		if ( ! isset( self::$instance ) && ! ( self::$instance instanceof EA_Archive_Landing ) ) {
			self::$instance = new EA_Archive_Landing();

			// Do stuff
			add_action( 'init',					array( self::$instance, 'register_cpt'    ) );
			add_action( 'template_redirect',	array( self::$instance, 'redirect_single' ) );
			add_action( 'admin_bar_menu',		array( self::$instance, 'admin_bar_link' ), 90 );
		}
		return self::$instance;
	}

	/**
	 * Get taxonomy
	 *
	 */
	function get_taxonomy() {
		$taxonomy = is_category() ? 'category' : ( is_tag() ? 'post_tag' : get_query_var( 'taxonomy' ) );
		if( in_array( $taxonomy, ea_archive_landing()->supported_taxonomies ) )
			return $taxonomy;
		else
			return false;
	}

	/**
	 * Get Archive ID
	 *
	 */
	function get_archive_id() {
		$taxonomy = ea_archive_landing()->get_taxonomy();
		if( empty( $taxonomy ) )
			return false;

		$loop = new WP_Query( array(
			'post_type' => 'archive_landing',
			'posts_per_page' => 1,
			'fields' => 'ids',
			'no_found_rows' => true,
			'update_post_term_cache' => false,
			'update_post_meta_cache' => false,
			'tax_query' => array(
				array(
					'taxonomy' => $taxonomy,
					'field' => 'term_id',
					'terms' => array( get_queried_object_id() ),
					'include_children' => false,
				)
			)
		));

		if( empty( $loop->posts ) )
			return false;
		else
			return $loop->posts[0];

	}

	/**
	 * Register the custom post type
	 *
	 * @since 1.2.0
	 */
	function register_cpt() {

		$labels = array(
			'name'               => 'Landing Pages',
			'singular_name'      => 'Landing Page',
			'add_new'            => 'Add New',
			'add_new_item'       => 'Add New Page',
			'edit_item'          => 'Edit Page',
			'new_item'           => 'New Page',
			'view_item'          => 'View Page',
			'search_items'       => 'Search Pages',
			'not_found'          => 'No Pages found',
			'not_found_in_trash' => 'No Pages found in Trash',
			'parent_item_colon'  => 'Parent Page:',
			'menu_name'          => 'Archive Landing',
		);

		$args = array(
			'labels'              => $labels,
			'hierarchical'        => false,
			'supports'            => array( 'title', 'revisions' ),
			'taxonomies'          => ea_archive_landing()->supported_taxonomies,
			'public'              => true,
			'show_ui'             => true,
			'show_in_menu'        => true,
			'show_in_nav_menus'   => false,
			'publicly_queryable'  => true,
			'exclude_from_search' => false,
			'has_archive'         => false,
			'query_var'           => true,
			'can_export'          => true,
			'rewrite'             => false,
			'menu_icon'           => 'dashicons-editor-table', // https://developer.wordpress.org/resource/dashicons/
		);

		register_post_type( 'archive_landing', $args );

	}

	/**
	 * Redirect single
	 *
	 */
	function redirect_single() {
		if( ! is_singular( 'archive_landing' ) )
			return;

		$supported = ea_archive_landing()->supported_taxonomies;
		if( 1 === count( $supported ) ) {
			$taxonomy = array_pop( $supported );
		} else {
			$taxonomy = get_post_meta( get_the_ID(), 'ea_connected_taxonomy', true );
		}
		$term = get_post_meta( get_the_ID(), 'ea_connected_' . $taxonomy, true );


		if( empty( $term ) ) {
			$redirect = home_url();
		} else {
			$term = get_term_by( 'term_id', $term, $taxonomy );
			$redirect = get_term_link( $term, $taxonomy );
		}

		wp_redirect( $redirect );
		exit;
	}


	/**
	 * Admin Bar Link
	 *
	 */
	 function admin_bar_link( $wp_admin_bar ) {
		 $taxonomy = ea_archive_landing()->get_taxonomy();
		 if( ! $taxonomy )
		 	return;

		if( ! ( is_user_logged_in() && current_user_can( 'edit_post' ) ) )
			return;

		$archive_id = ea_archive_landing()->get_archive_id();
		if( !empty( $archive_id ) ) {
			$wp_admin_bar->add_node( array(
				'id' => 'ea_archive_landing',
				'title' => 'Edit Landing Modules',
				'href'  => get_edit_post_link( $archive_id ),
			) );

		} else {
			$wp_admin_bar->add_node( array(
				'id' => 'ea_archive_landing',
				'title' => 'Add Landing Modules',
				'href'  => admin_url( 'post-new.php?post_type=archive_landing' )
			) );
		}
	 }
}

/**
 * The function provides access to the class methods.
 *
 * Use this function like you would a global variable, except without needing
 * to declare the global.
 *
 * @return object
 */
function ea_archive_landing() {
	return EA_Archive_Landing::instance();
}
ea_archive_landing();

Bill Erickson

Bill Erickson is a freelance WordPress developer and a contributing developer to the Genesis framework. For the past 14 years he has worked with attorneys, publishers, corporations, and non-profits, building custom websites tailored to their needs and goals.

Ready to upgrade your website?

I build custom WordPress websites that look great and are easy to manage.

Let's Talk

Reader Interactions

Comments

  1. Hans Schuijff says

    Hi Bill,

    Thanks for sharing this solution. I find your articles very useful and you do us a great service by sharing your approach and solutions so willingly. So thanks for that. It is a great help! I’m on the brink of building a new genesis child theme for one of my websites and I will gladly take what you share in doing so.

    I had been wondering why you removed the ACF-part from your core functionality, since I remembered your previous advice about the front-end performance of it. Going back to that article I have read now that the bloat is now fixed in ACF, so that explains it.

    The discussion below that article focusses a lot on CMB2 and you stated then you prefer that except for the more complex fields that are not yet covered by CMB. And at some point you even say

    “I’ve actually stopped using ACF. I’m tired of their non-backwards-compatible changes. I switched to Carbon Fields, which is the perfect balance between developer friendly (like CMB2) and a nice looking backend UI (like ACF).”.

    That comment is from many years back of course, but I am interested to hear how you stand in that now. Is ACF back in your favor now, since you often seem to say you use it for block building?

    In this topic, I wonder, when you advice to use meta instead of Gutenberg, how that will change when the longer term ambitions of Gutenberg further develop and more and more parts of wordpress will be included. At some point it seems we need to adopt a block based solution for these modules too.

    What are your thoughts on that?

    Regards,

    Hans

    p.s. I really miss being automatically informed about reactions on my reactions. Having to keep track of them manually…

Leave A Reply