How We Built an AJAX Member Directory on a Divi Site

COVESA had a member directory problem most growing organizations eventually hit. The Connected Vehicle Systems Alliance lists every member company on its About page, and that list had grown past 80 organizations. Automakers like BMW, Ford, and Honda. Suppliers like Bosch and HARMAN. Chip makers like Arm and NXP. The client wanted visitors to sort that wall of logos two different ways, by industry and by membership level, without reloading the page every time. That is the job an AJAX member directory is built for.

Here's the thing about a directory like this. It is not hard to show 87 logos in a grid. It gets interesting when the same set of logos needs to regroup on demand, stay fast on a phone, and keep working as the client adds a new member most months. COVESA runs on WordPress with the Divi builder, so whatever I built had to live comfortably inside that stack.

This is the story of how the directory actually works, the decisions behind it, and the tradeoffs I accepted to ship it. If you manage a WordPress site with a member list, a partner wall, or any grid that needs filtering, the same pattern applies.

The Directory That Regroups Without a Page Reload

The finished AJAX member directory sits in the Current Members section of the COVESA About page. At the top is a single dropdown labeled View By. It offers two choices: Industry and Member Level. Below it is a grid of company logos, each one linking out to that member's website.

Pick Industry and the logos group under headings like OEMs, First Tiers, Suppliers, and Silicon or Semiconductors. Switch to Member Level and the same logos regroup by membership tier. The grid updates in place. No new page loads, no scroll jump, no spinner most people would notice.

Each logo carries a title attribute with the company name and the year it joined, so hovering over the Arm logo shows "ARM LTD, joined in 2010." Most people don't realize how much that small touch does for a directory. It turns a static logo wall into something with history in it.

As of this writing the directory holds 87 member organizations across five industry groupings. The suppliers group alone runs to 64 companies. Some members date back to 2009. Nineteen joined in 2025 and eight more have joined so far in 2026, which tells you why the maintenance side mattered as much as the build.

Why I Used admin-ajax and Server-Rendered HTML

The whole directory rides on one old, reliable WordPress pattern. There is no framework, no build step, and no third-party plugin doing the heavy lifting. Here is how the pieces fit together.

The AJAX Call Is Deliberately Small

The front end is about 20 lines of jQuery inside a Divi Code Module. When the View By dropdown changes, it fires a POST request to WordPress at wp-admin/admin-ajax.php with two pieces of data: the action name, filter_members, and the selected value, either industry or level.

According to the WordPress Plugin Handbook, admin-ajax.php is the built-in endpoint WordPress ships for handling this kind of request, and hooking a custom action to it is the standard way to run server code from the browser. I did not need a REST route or a third-party plugin. The hook was already there.

jQuery(document).ready(function($) {
    $('#viewBy').on('change', function() {
        var viewBy = $(this).val();
        $.ajax({
            url: '/wp-admin/admin-ajax.php',
            type: 'POST',
            data: { action: 'filter_members', viewBy: viewBy },
            success: function(response) {
                $('#members-container').html(response);
            }
        });
    });
});

When the response comes back, one line replaces the contents of the members container with the returned markup. That is the whole client side.

The Server Returns HTML, Not JSON

Here is the decision that shapes everything else. The filter_members handler on the server does not return raw data for the browser to assemble. It returns finished HTML, the exact grid markup with headings, rows, logos, and links already built. jQuery just drops it into the page.

Most tutorials tell you to return JSON and template it on the client. I went the other way on purpose. The grouping logic, the category headings, the row structure, all of it lives in one place on the server. There is no second copy of the layout in JavaScript that can drift out of sync. For a directory that one person maintains, that is one less thing to break.

It Lives in a Divi Code Module

COVESA is a Divi site, so the natural home for this was a Code Module dropped into the About page layout. The module holds the markup for the dropdown, the scoped CSS for the grid, and the jQuery. Divi renders the rest of the page around it and never touches what is inside.

That keeps the directory portable. It is not tangled into a child theme or a plugin the client would have to manage. If you are weighing where custom code like this should live, I wrote a whole piece on code snippets versus child themes that covers the tradeoffs.

The Grid Is Plain Flexbox

The layout is CSS flexbox, not a grid framework. Each logo sits in a fixed-height container set to 8 percent width on desktop, which lands roughly ten logos per row. Two media queries handle smaller screens. At 980 pixels the containers shrink, and at 768 pixels they jump to 15 percent width so logos stay tappable on a phone. I picked those breakpoints deliberately, and if you want the reasoning I use for choosing them, that is in my post on what screen size to design for.

The Tradeoffs I Accepted To Ship It

Every real build gives something up. This one gave up a few things worth naming.

Returning HTML instead of JSON means the response is heavier than a pure data payload would be. For 87 logos that difference is trivial, and the readability win is worth it. If this directory ever held two thousand entries, I would reconsider and paginate.

The AJAX response is not cached. Every time someone switches the dropdown, the server rebuilds the grid from scratch. Again, at this size that is a few milliseconds of database work. It would matter at a different scale.

The bigger honest issue was not the AJAX at all. It was Divi's CSS caching. COVESA had intermittent styling problems where the grid would render wrong until a cache cleared. Chasing that down meant working through Divi's Dynamic CSS and Critical CSS settings and coordinating with their host on server-side caching. The custom code was the easy part. The caching was the part that ate real hours.

There is also the logo problem. Members send logos in every format and size imaginable, from tiny PNGs to oversized JPEGs. The container CSS scales them to fit, but garbage in is still garbage out. Keeping that logo wall looking clean is ongoing work, and image handling is its own discipline. I covered the performance side of that in rightsizing images for a website.

What COVESA Runs Today

The AJAX member directory is live on the COVESA About page and has been the backbone of that section since launch. It holds 87 organizations right now, and that number climbs most months as new members join. Nineteen came aboard in 2025. Eight have joined in the first half of 2026.

Adding a member is a small, repeatable job. A new logo, a company name, a join year, and the membership details. The visitor-facing filtering keeps working without any changes to the code.

The two-way sorting earns its keep. A visitor scouting the alliance can look at it by industry to see which automakers and suppliers are involved. Someone evaluating membership can flip to Member Level to understand the tiers. Same data, two lenses, no page reload between them.

What struck me going through this again is how boring the maintenance has been, and I mean that as praise. The thing was built to be updated by a human on a normal afternoon, and it has held up.

Would I Build It This Way Again

For this client, yes, with almost no changes. The admin-ajax and server-rendered HTML combination is old-fashioned by 2026 standards. There are slicker ways to do it with the REST API and a JavaScript framework. But slicker is not the goal when one person maintains the site and the whole thing needs to keep working for years with minimal attention.

The good news is that the simple version is also the durable version here. Fewer moving parts, one source of truth for the layout, no build step, no framework to keep updated. That is the right call for an AJAX member directory that changes slowly and predictably.

If I were starting over, the one thing I would tackle earlier is the caching. I treated it as a styling bug at first. It was really an architecture conversation with the host. Naming that sooner would have saved time.

If your organization has a member wall, a partner grid, or a directory that needs to filter, this pattern holds up. It is not fashionable. It is reliable. When you are the one maintaining it, reliable wins. This is the kind of custom WordPress work I do for clients, and directories like this are a common ask.

The Complete Code

Here is the whole directory in one block, copied from the running installation. It is a single PHP snippet that carries its own JavaScript and its own CSS, so there is no separate file to enqueue. Paste it into Code Snippets, set the scope to run everywhere, and drop the shortcode on the page. The AJAX handler is registered inside the same snippet for both logged-in and logged-out visitors.

Copy the block below with the Copy button in its top right corner.

// Register the shortcode
function current_members_shortcode_ajax() {
    global $post, $wpdb;

    // JavaScript for AJAX
    $script = "
    <script>
    jQuery(document).ready(function($) {
        $('#viewBy').on('change', function() {
            var viewBy = $(this).val();

            $.ajax({
                url: '".admin_url('admin-ajax.php')."',
                type: 'POST',
                data: {
                    action: 'filter_members',
                    viewBy: viewBy
                },
                success: function(response) {
                    $('#members-container').html(response);
                },
                error: function(error) {
                    console.log('Error:', error);
                }
            });
        });
    });
    </script>";

    // CSS for styling
    $style = "
    <style>
    .container {
        width: 100%;
        margin: auto;
        padding: 20px;
        box-sizing: border-box;
    }
    .heading {
        text-align: center;
        font-size: 29px;
        color: #0C71C3!important;
        font-weight: 800;
        margin-bottom: 20px;
    }
    .row {
        display: flex;
        flex-wrap: wrap;
        gap: 5%;
        align-items: center;
        justify-content: center;
    }
    .image-container {
        width: 8%;
        height: 100px;
        display: flex;
        align-items: center;
        justify-content: center;
        overflow: hidden;
    }
    .image-container a {
        display: flex;
        align-items: center;
        justify-content: center;
        width: 100%;
        height: 100%;
        text-decoration: none;
    }
    .image-container img {
        max-width: 100%;
        max-height: 100%;
        height: auto;
        width: auto;
    }
    @media (max-width: 980px) {
        .row {
            gap: 5%;
        }
        .image-container {
            height: 90px;
        }
        .heading {
            font-size: 26px;
        }
        .view-by-text {
            font-size: 17px;
        }
    }
    @media (max-width: 768px) {
        .row {
            gap: 8%;
        }
        .image-container {
            width: 15%;
            height: 80px;
        }
        .heading {
            font-size: 24px;
        }
        .view-by-text {
            font-size: 16px;
        }
    }
    .form-container {
        display: flex;
        flex-direction: column;
        align-items: center;
        justify-content: center;
        text-align: center;
    }
    .view-by-text {
        margin-bottom: 20px;
        font-family: 'Open Sans', Arial, sans-serif;
        font-size: 18px;
        font-weight: 600;
        color: #1d1d1d;
    }
    #viewByForm {
        display: flex;
        align-items: center;
        background: #FFFFFF;
        box-shadow: 0px 3px 6px #00000029;
        border-radius: 5px;
        padding: 8px 16px;
        width: 300px;
        margin: auto;
    }
    #viewBy {
        appearance: none;
        -webkit-appearance: none;
        -moz-appearance: none;
        background: url('https://covesa.global/wp-content/uploads/2024/04/down-arrow.png') no-repeat right;
        background-size: 12px;
        width: 100%;
    }
    </style>";

    // HTML for form and initial content
    $output = $script . $style;
    $output .= "
    <div class='form-container'>
        <div class='view-by-text'>View By</div>
        <form id='viewByForm' action='javascript:void(0);' method='POST'>
            <select id='viewBy' name='viewBy' style='border: 0px solid #CCCCCC; border-radius: 20px; padding: 8px 16px; outline: none; font-family: Open Sans, Arial, sans-serif; font-size: 18px; cursor: pointer;'>
                <option value='industry'>Industry</option>
                <option value='level'>Member Level</option>
            </select>
        </form>
    </div>
    <div id='members-container' class='container'>";

    // Initial content generation
    $viewBy = 'default_value'; // Initial value

    $fjoined = "substring(pm3.meta_value,1,4)";
    switch ($viewBy) {
        case 'level':
            $qsort = "levelorder";
            break;
        case 'date':
            $qsort = "pm3.meta_value";
            $fjoined = "substring(pm3.meta_value,1,4)";
            break;
        default:
            $qsort = "industryorder";
    }

    $sql = "
    SELECT 
        p.ID, 
        p.post_title AS Member, 
        case
        when pm1.meta_value = 'OEM' then 'OEMS'
        when pm1.meta_value = 'Tier 1' then 'First Tiers'
        when pm1.meta_value = 'Other' then 'Others'
        when pm1.meta_value = 'Silicon or Semiconductor' then 'Silicon or Semiconductors'
        else pm1.meta_value end AS industry,
        case
        when pm1.meta_value = 'OEM' then 1
        when pm1.meta_value = 'Tier 1' then 2
        when pm1.meta_value = 'OSV, Middleware, Hardware & Services Suppliers' then 3
        when pm1.meta_value = 'Silicon or Semiconductor' then 4
        when pm1.meta_value = 'Other' then 5
        else 0 end AS industryorder,
        case
        when pm2.meta_value = 'Charter' then 'Founding Charter and Charter'
        else pm2.meta_value end AS level,
        case
        when pm2.meta_value = 'Charter' then 1
        when pm2.meta_value = 'Core' then 2
        when pm2.meta_value = 'Associate' then 3
        when pm2.meta_value = 'Start-up Associate' then 4
        when pm2.meta_value = 'Start-up Plus Associate' then 5
        else 0 end AS levelorder,
        substring(pm3.meta_value,1,4) AS joined,
        pm4.meta_value AS url,
        wp.guid AS logo
    FROM 
        wp_posts AS p
    LEFT JOIN 
        wp_postmeta AS pm1 ON p.ID = pm1.post_id AND pm1.meta_key = 'industry'
    LEFT JOIN 
        wp_postmeta AS pm2 ON p.ID = pm2.post_id AND pm2.meta_key = 'level'
    LEFT JOIN 
        wp_postmeta AS pm3 ON p.ID = pm3.post_id AND pm3.meta_key = 'joined'
    LEFT JOIN 
        wp_postmeta AS pm4 ON p.ID = pm4.post_id AND pm4.meta_key = 'url'
    LEFT JOIN 
        wp_postmeta AS pm5 ON p.ID = pm5.post_id AND pm5.meta_key = 'logo'
    LEFT JOIN 
        wp_posts AS wp ON wp.ID = pm5.meta_value
    WHERE 
        p.post_status = 'publish' 
        AND p.post_type = 'member'
        and wp.guid is not null
    ORDER BY 
        {$qsort}, Member;
    ";

    $results = $wpdb->get_results($sql, OBJECT);

    if (!empty($results)) {
        // Variable to keep track of the current category
        $currentCategory = null;
        $colCount = 0;
        $rows = null;
        foreach ($results as $member) {
            switch ($viewBy) {
                case 'level':
                    $hvalue = $member->level;
                    break;
                case 'date':
                    $hvalue = $member->joined;
                    break;
                default:
                    $hvalue = $member->industry;
            }
            // Check if the category has changed or if column count has reached 8
            if ($currentCategory !== $hvalue || $colCount >= 8) {
                // If not the first iteration, close the previous row
                if ($currentCategory !== null) {
                    $rows .= '</div>'; // Close the previous row
                }
                if ($currentCategory !== $hvalue) {
                    if ($currentCategory !== null) {
                        $rows .=  '</div>';
                    }
                    $rows .= '<div><h4 class="heading">'. $hvalue.'</h4></div>';
                    $rows .=  '<div style="background-color:white; padding: 20px; margin-bottom: 50px; border-radius: 10px;">';
                }
                // Start a new row and reset column count
                $rows .=  '<div class="row">';
                $colCount = 0;
            }
            // Display the logo image
            $rows .=  '<div class="image-container">';
            $rows .=  '<a href="'.$member->url.'"><img src="' . htmlspecialchars($member->logo) . '" alt="'.$member->Member.'" title="'.$member->Member.', joined in '.$member->joined.'"></a>';
            $rows .=  '</div>';
            // Update the current category and increment column count
            $currentCategory = $hvalue;
            $colCount++;
            // If this is the last item, close the div
            if (end($results) === $member && $colCount <= 8) {
                $rows .=  '</div>'; // Close the last row if needed
            }
        }
        // Check if the last row was closed in the loop
        if ($colCount > 0) {
            $rows .=  '</div>'; // Ensure the row is closed
        }
    } else {
        $rows = "No members found.";
    }

    $output .= $rows;
    $output .= "</div>";

    return $output;
}
add_shortcode('current-members-ajax', 'current_members_shortcode_ajax');

// Create the AJAX handler
function filter_members() {
    global $wpdb;

    $viewBy = isset($_POST['viewBy']) ? $_POST['viewBy'] : 'default_value';

    $fjoined = "substring(pm3.meta_value,1,4)";
    switch ($viewBy) {
        case 'level':
            $qsort = "levelorder";
            break;
        case 'date':
            $qsort = "pm3.meta_value";
            $fjoined = "substring(pm3.meta_value,1,4)";
            break;
        default:
            $qsort = "industryorder";
    }

    $sql = "
    SELECT 
        p.ID, 
        p.post_title AS Member, 
        case
        when pm1.meta_value = 'OEM' then 'OEMS'
        when pm1.meta_value = 'Tier 1' then 'First Tiers'
        when pm1.meta_value = 'Other' then 'Others'
        when pm1.meta_value = 'Silicon or Semiconductor' then 'Silicon or Semiconductors'
        else pm1.meta_value end AS industry,
        case
        when pm1.meta_value = 'OEM' then 1
        when pm1.meta_value = 'Tier 1' then 2
        when pm1.meta_value = 'OSV, Middleware, Hardware & Services Suppliers' then 3
        when pm1.meta_value = 'Silicon or Semiconductor' then 4
        when pm1.meta_value = 'Other' then 5
        else 0 end AS industryorder,
        case
        when pm2.meta_value = 'Charter' then 'Founding Charter and Charter'
        else pm2.meta_value end AS level,
        case
        when pm2.meta_value = 'Charter' then 1
        when pm2.meta_value = 'Core' then 2
        when pm2.meta_value = 'Associate' then 3
        when pm2.meta_value = 'Start-up Associate' then 4
        when pm2.meta_value = 'Start-up Plus Associate' then 5
        else 0 end AS levelorder,
        substring(pm3.meta_value,1,4) AS joined,
        pm4.meta_value AS url,
        wp.guid AS logo
    FROM 
        wp_posts AS p
    LEFT JOIN 
        wp_postmeta AS pm1 ON p.ID = pm1.post_id AND pm1.meta_key = 'industry'
    LEFT JOIN 
        wp_postmeta AS pm2 ON p.ID = pm2.post_id AND pm2.meta_key = 'level'
    LEFT JOIN 
        wp_postmeta AS pm3 ON p.ID = pm3.post_id AND pm3.meta_key = 'joined'
    LEFT JOIN 
        wp_postmeta AS pm4 ON p.ID = pm4.post_id AND pm4.meta_key = 'url'
    LEFT JOIN 
        wp_postmeta AS pm5 ON p.ID = pm5.post_id AND pm5.meta_key = 'logo'
    LEFT JOIN 
        wp_posts AS wp ON wp.ID = pm5.meta_value
    WHERE 
        p.post_status = 'publish' 
        AND p.post_type = 'member'
        and wp.guid is not null
    ORDER BY 
        {$qsort}, Member;
    ";

    $results = $wpdb->get_results($sql, OBJECT);

    if (!empty($results)) {
        // Variable to keep track of the current category
        $currentCategory = null;
        $colCount = 0;
        $rows = null;
        foreach ($results as $member) {
            switch ($viewBy) {
                case 'level':
                    $hvalue = $member->level;
                    break;
                case 'date':
                    $hvalue = $member->joined;
                    break;
                default:
                    $hvalue = $member->industry;
            }
            // Check if the category has changed or if column count has reached 8
            if ($currentCategory !== $hvalue || $colCount >= 8) {
                // If not the first iteration, close the previous row
                if ($currentCategory !== null) {
                    $rows .= '</div>'; // Close the previous row
                }
                if ($currentCategory !== $hvalue) {
                    if ($currentCategory !== null) {
                        $rows .=  '</div>';
                    }
                    $rows .= '<div><h4 class="heading">'. $hvalue.'</h4></div>';
                    $rows .=  '<div style="background-color:white; padding: 20px; margin-bottom: 50px; border-radius: 10px;">';
                }
                // Start a new row and reset column count
                $rows .=  '<div class="row">';
                $colCount = 0;
            }
            // Display the logo image
            $rows .=  '<div class="image-container">';
            $rows .=  '<a href="'.$member->url.'"><img src="' . htmlspecialchars($member->logo) . '" alt="'.$member->Member.'" title="'.$member->Member.', joined in '.$member->joined.'"></a>';
            $rows .=  '</div>';
            // Update the current category and increment column count
            $currentCategory = $hvalue;
            $colCount++;
            // If this is the last item, close the div
            if (end($results) === $member && $colCount <= 8) {
                $rows .=  '</div>'; // Close the last row if needed
            }
        }
        // Check if the last row was closed in the loop
        if ($colCount > 0) {
            $rows .=  '</div>'; // Ensure the row is closed
        }
    } else {
        $rows = "No members found.";
    }

    echo $rows;

    wp_die();
}
add_action('wp_ajax_filter_members', 'filter_members');
add_action('wp_ajax_nopriv_filter_members', 'filter_members');

Sources

0 Comments

Submit a Comment

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.

Post Search

Follow Us

Feel free to follow us on social media for the latest news and more inspiration.

Related Content