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] => [image] => 34679 ) [1] => Array ( [title] => Main Dishes [url] => [image] => 53652 ) [2] => Array ( [title] => Pyrex Glassware [url] => [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 * * @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', // ); 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


  1. ipeyato says

    Sir, please give some code example to handle these this -> apply_filters( ‘ea_the_content’, $module[‘content’] ) to handle the markup. And I’m using underscore theme. Thanks

  2. Anoop says

    Hi Bill ,
    Thank you for the great tutorial , Is it possible to create flexible layout without ACF ie; programmatically ?

    • Bill Erickson says

      In this example, ACF is only used for the backend content entry, it is not used for displaying the flexible layout on the frontend. Yes, you could remove ACF completely and custom build your backend metabox for managing the content, but that would be a lot of work with minimal benefit.

Leave A Reply