Black Friday 2025
Meta Box

How to Filter Posts by Custom Fields & Taxonomy with Meta Box

Previously on the Meta Box blog, we shared different ways to filter content using custom fields, taxonomy, and relationships. Today, we’ll go one step further by bringing everything together in one practical example with Meta Box that combines taxonomy and custom field filtering.

This is a very common requirement for service-based websites, as well as listing websites such as events, courses, job listings, or real estate. In this tutorial, we’ll demonstrate this using a spa services website as a real-world example, allowing visitors to filter services by category - such as Body, Facial, Hair or Laser, and sort them by the number of sessions stored in custom fields.

Result

Let’s dive in!

Video Version

Before Getting Started

In this tutorial, we’ll create a custom post type to manage all the spa services. Each service belongs to one or more taxonomies for categorization and also contains custom fields for service details like treatment plans, price, and duration.

So, I recommend using Meta Box AIO, which includes the framework and all extensions, such as:

  • MB Admin Columns: to display service type directly in the dashboard;
  • MB Custom Post Type: to create a custom post type for spa service and taxonomies for service categories;
  • MB Builder: to create custom fields for storing service information;
  • MB Views: to create a template and implement the front-end filtering logic.

Create a Custom Post Type & Taxonomy

For this tutorial, I’ve already prepared a custom post type to store spa services. Each service is saved as a post in this post type, making it easy to manage and display later using MB Views.

Custom post type

In the Meta Box screen, move to Taxonomies to create a taxonomy for service type.

Create taxonomy

Make sure to choose the associated post type.

Associate post types

And in the Advanced tab, enable the option to display the taxonomy in the admin columns. This option is available when the MB Admin Columns extension is activated, and it allows you to display a taxonomy column on the post type listing screen.

Show admin column to show taxonomy on the post type listing screen.

We can create terms as you want.

Terms

Create Custom Fields for Services

Go to Meta Box > Custom Fields to create some custom fields for storing information for each service.

Create fields

I’ve created the field group with some fields like price, duration, description, gallery, and treatment plan so that you can choose how many sections we need for that service.

All fields

The Treatment Plan field is the most important one in this tutorial because we’ll later use it to filter services on the front end based on custom field values. Note that you can create any field you need for the service listings.

Once done, move to the Settings tab and set the Location as Post Type. Choose Services to ensure that the custom fields appear only for service posts.

Set location for post type

Now, you’ll see all the fields we’ve just created displayed under the post editor along with the taxonomy section, where you can assign service types.

Custom fields and taxonomy in post editor

You can now fill in spa service details. And then, we have all the posts with the taxonomy section displayed in the admin dashboard.

All posts in dashboard

Show Spa Services Listing

Before creating any filters, we first need to make sure that our spa services can already be displayed correctly on the frontend.

First, create a new page specifically for the spa service listing. This page will act as the main service archive for users to browse.

Create new page

Next, go to Meta Box > Views and create a new template specifically for this page.

With MB Views, you can add some lines of code directly in the Template tab.

Create view

In here: This line is to query and retrieve all posts from the Service post type.

{% set args = { post_type: 'service', posts_per_page: -1, orderby: 'date', order: 'DESC' } %}
{% set posts = mb.get_posts(args) %}

MB Views makes this step very convenient; you don’t need to write all the codes manually. To display the service information from your custom fields, simply click the Insert Field button and select any fields from the right sidebar.

Insert fields

Make sure to include the Treatment Plan field. This field will be important later, because we’ll use it as one of the criteria for filtering the spa services on the frontend.

After inserting all the fields, move to the Settings section to set where this template will appear. Set the Type to Singular, and choose the page you created for the spa service listings.

Set location for view

On the frontend, you’ll see all the spa service information displayed. At this stage, it appears as a basic list without any styling.

Page without styling

To make it look more visually appealing, add some div tags and classes in the Template tab, and modify the code.

Add classes and div

Then, switch to the CSS tab to style the layout.

Css tab

.services-grid {
    position: relative;
}

.service-item {
    float: left;
    width: 25%;
    box-sizing: border-box;
    padding: 10px;
}

.service-item>div {
    background: #fff;
    border-radius: 12px;
    overflow: hidden;
    box-shadow: 0 8px 24px rgba(0, 0, 0, .06);
    transition: .3s;
}

.service-item:hover>div {
    transform: translateY(-4px);
}

.service-thumb {
    width: 100%;
    aspect-ratio: 16 / 9;
    background: #eee;
    overflow: hidden;
}

.service-thumb img {
    width: 100%;
    height: 100%;
    object-fit: cover;
    display: block;
}

.service-body {
    padding: 16px;
}

.service-title {
    font-size: 18px;
    font-weight: 600;
    margin: 0 0 10px;
}

.service-title a {
    text-decoration: none;
}

.service-meta {
    display: flex;
    flex-direction: column;
    gap: 6px;
    margin-top: 8px;
}

.service-price {
    font-size: 18px;
    font-weight: 700;
    color: #c89b3c;
    line-height: 1.2;
}

.service-price .currency {
    font-size: 14px;
    vertical-align: top;
    margin-right: 2px;
    opacity: 0.8;
}

.service-duration {
    font-size: 12px;
    color: #444;
    background: #f3f3f3;
    padding: 4px 8px;
    border-radius: 12px;
}

.service-plan {
    font-size: 14px;
    padding: 5px 0px;
}

.service-price {
    color: #c89b3c;
}

Now, the service listings look much better.

Page with no filtering

Let’s move on to create filtering by custom field and taxonomy.

Create Filters by Taxonomy & Field's Value

Back to the template created with MB Views, we’ll add a bit of logic to filter spa services dynamically based on user selections on the frontend.

Add code for filters

<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.7.1/jquery.min.js"></script>
<script src="https://npmcdn.com/[email protected]/dist/isotope.pkgd.js"></script>

{% set args = { post_type: 'service', posts_per_page: -1, orderby: 'date', order: 'DESC'} %}
{% set posts = mb.get_posts(args) %}

<div class="service-filters">
    <div class="service-filter service-type-filter">
        <button class="filter-btn active" data-filter="*" data-group="type">All Services</button>
        {% set service_types = mb.get_terms({
            taxonomy: 'service-type',
            hide_empty: true
        }) %}
        {% for term in service_types %}
            <button
                class="filter-btn"
                data-filter=".{{ term.slug }}"
                data-group="type">
                {{ term.name }}
            </button>
        {% endfor %}
    </div>

    <div class="service-filter service-plan-filter">
        <button class="filter-btn active" data-filter="*" data-group="plan">All Plans</button>
        {% set plans = [] %}
        {% for post in posts %}
            {% if post.treatment_plan.label not in plans %}
                {% set plans = plans|merge([post.treatment_plan.label]) %}
            {% endif %}
        {% endfor %}
        {% set plans = plans|sort((a, b) => a <=> b) %}
        {% for plan in plans %}
            {% set plan_class = 'plan_' ~ plan|lower|replace({' ': '_'}) %}
            <button
                class="filter-btn"
                data-filter=".{{ plan_class }}"
                data-group="plan">
                {{ plan }}
            </button>
        {% endfor %}
    </div>
</div>

<div class="services-grid">
    {% for post in posts %}
    {% set post_terms = mb.get_the_terms(post.ID, 'service-type') %}
    {% set term_classes = [] %}
    {% if post_terms %}
        {% for term in post_terms %}
            {% set term_classes = term_classes|merge([term.slug]) %}
        {% endfor %}
    {% endif %}
    {% set plan_class = 'plan_' ~ post.treatment_plan.label|lower|replace({' ': '_'}) %}
    <div class="service-item {{ term_classes|join(' ') }} {{ plan_class }}">
        <div>
            <div class="service-thumb">
                <img src="{{ post.thumbnail.full.url }}" alt="{{ post.title }}">
            </div>
            <div class="service-body">
                <h3 class="service-title">
                    <a href="{{ post.url }}">{{ post.title }}</a>
                </h3>
                <div class="service-meta">
                    <div class="service-price">$ {{ post.price }}</div>
                    <div class="service-plan">Treatment Plan: {{ post.treatment_plan.label }}</div>
                    <div class="service-duration">⏱ {{ post.duration }} mins</div>
                </div>
            </div>
        </div>
    </div>
{% endfor %}
</div>

In there:

Wee load jQuery and Isotope libraries to handle user interactions and enable dynamic filtering and layout updates on the frontend without reloading the page.

<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.7.1/jquery.min.js"></script>
<script src="https://npmcdn.com/[email protected]/dist/isotope.pkgd.js"></script>

We use the service-filters class to create a wrapper for the filter section.

We start by creating a taxonomy-based filter. And create the All Services button, which resets the filter and shows all services by default.

<div class="service-filter service-type-filter">
<button class="filter-btn active" data-filter="*" data-group="type">All Services</button>

The following code retrieves all terms from the service-type taxonomy and only includes terms that already have services assigned to them.

{% set service_types = mb.get_terms({
    taxonomy: 'service-type',
    hide_empty: true
}) %}

We loop through each service type and render a filter button for it.

{% for term in service_types %}
   <button
        class="filter-btn"
        data-filter=".{{ term.slug }}"
        data-group="type">
        {{ term.name }}
    </button>
{% endfor %}

Each button targets services with a matching CSS class, so every service item below must include the corresponding term slug class for the filter to work correctly.

Then, we move on to filtering by custom field values, using the Treatment Plan field. Add another button named all plans, which resets the custom field filter and shows all services.

<button class="filter-btn active" data-filter="*" data-group="plan">All Plans</button>

We loop through all the posts to collect the treatment plans. The goal is to get the label that will be displayed to users. We avoid duplicates by only adding a label if it’s not already in the list. This ensures each plan shows up just once.

{% set plans = [] %}
{% for post in posts %}
    {% if post.treatment_plan.label not in plans %}
        {% set plans = plans|merge([post.treatment_plan.label]) %}
    {% endif %}
{% endfor %}

{% set plans = plans|sort((a, b) => a <=> b) %} is to sort the plan labels alphabetically so they appear in order in the filter. This makes it easier for users to find a plan.

{% for plan in plans %}
    {% set plan_class = 'plan_' ~ plan|lower|replace({' ': '_'}) %}
    <button
        class="filter-btn"
        data-filter=".{{ plan_class }}"
        data-group="plan">
        {{ plan }}
    </button>
{% endfor %}

This part is to loop through all plans and render a filter button so that users can see the plan name and click filters as they want.

After having the filter buttons, we need to add matching CSS classes to each post so the filters actually work.

For each post, this line below gets the terms of the post in the service-type taxonomy.

{% set post_terms = mb.get_the_terms(post.ID, 'service-type') %}

We initialize an empty array, then add each term’s slug so it can be used as a CSS class.

{% set term_classes = [] %}
{% if post_terms %}
    {% for term in post_terms %}
        {% set term_classes = term_classes|merge([term.slug]) %}
    {% endfor %}
{% endif %}

Next, we create a class for the treatment plan.

{% set plan_class = 'plan_' ~ post.treatment_plan.label|lower|replace({' ': '_'}) %}

Finally, the following line ensures that each item has all the necessary filter classes for the filtering logic to work properly.

<div class="service-item {{ term_classes|join(' ') }} {{ plan_class }}"

Move to the CSS tab, add more codes to style the filter section.

.service-filters {
    display: flex;
    flex-direction: column;
    gap: 20px;
    margin-bottom: 30px;
}

.service-filter {
    display: flex;
    gap: 12px;
    flex-wrap: wrap;
}

.filter-btn {
    padding: 10px 20px;
    border-radius: 20px;
    background: #f2f2f2;
    border: none;
    cursor: pointer;
    font-size: 16px;
    transition: all .2s ease;
}

.filter-btn:hover {
     background: #ddd;
}

.filter-btn.active {
    background: #898f39;
    color: #fff;
}

Now, in the Javascript tab, add scripts to make the filtering interactive.

Javascript tab

jQuery(function ($) {

    var iso = new Isotope($('.services-grid')[0], {
        itemSelector: '.service-item',
        layoutMode: 'fitRows'
    });

    var filters = { type: '*', plan: '*' };

    $('.filter-btn').on('click', function () {
        var g = $(this).data('group'),
            f = $(this).data('filter');

        $('.filter-btn[data-group="' + g + '"]').removeClass('active');
        $(this).addClass('active');

        filters[g] = f;

        iso.arrange({
            filter: Object.values(filters).filter(v => v !== '*').join('') || '*'
        });
    });

});

In there:

We initialize an Isotope library to handle the grid layout and dynamic show/hide of posts.

var iso = new Isotope($('.services-grid')[0], {
    itemSelector: '.service-item',
    layoutMode: 'fitRows'
});

var filters = { type: '*', plan: '*' } This line is to keep track of the current filter states in a filters object.

When a filter button is clicked, we update the active button styling and also update the corresponding filter in the filters object. And also retrieve the values from the data-filterand button-groupattributes.

$('.filter-btn').on('click', function () {
    var g = $(this).data('group'),
        f = $(this).data('filter');

The following code is to keep only one active button per group.

$('.filter-btn[data-group="' + g + '"]').removeClass('active');
$(this).addClass('active');

And filters[g] = f; updates the filter state for the group.

These codes below apply the selected filters to show only matching items, or all if none are selected.

iso.arrange({
    filter: Object.values(filters).filter(v => v !== '*').join('') || '*'
});

That’s all for the code. I’ve put everything on GitHub, so you can check it out there.

Move to the frontend to see the result.

result

Now the filter works perfectly: it dynamically shows or hides posts according to the categories, tags, and values from custom fields, so users can see exactly the items that match their selected filters.

Last Words

In this tutorial, we’ve shown you how to combine taxonomy and custom field filtering with Meta Box to build flexible and user-friendly listing pages. We hope this guide helps you apply these techniques to your own projects.

For more tips and examples on working with custom fields, relationships, and queries, be sure to explore other tutorials on the Meta Box blog.

 

Leave a Reply

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