The filters above the articles on this website look simple, which was the point. I wanted someone to choose Development or Reading and get a useful list, without turning a small blog into an application.
While building One Base, I kept the native WordPress Query Loop responsible for the posts, their layout and pagination. The additional work sits around it: reading a visitor’s selection, checking it against the available categories and adding that constraint to the query. That separation means the list can still be edited in Gutenberg.
This tutorial shows a smaller version of that approach. One Base has a reusable Query Filters block, support for several listings and optional dynamic navigation. Here, a small plugin and a shortcode provide a single category selector with ordinary page navigation. The PHP examples were checked against WordPress 7.1; the query hook itself has been available since WordPress 6.1.
Implementation notes reviewed: 30 September 2026. The examples below are adapted for this tutorial.
In this tutorial
What we’re building
We will add category links above one Query Loop on an Articles page. Selecting a link updates a URL such as /articles/?cn-category=development. WordPress then renders the filtered posts on the server.
The example leaves titles, excerpts, dates, pagination and the empty state as native blocks. It does not change the main archive query or require JavaScript. It also preserves any category restrictions already set by the editor.
Before you start
You need a test WordPress site, published posts assigned to categories, and an Articles page containing a Query Loop. Use WordPress 6.1 or later for the hook below. Work on staging first and keep the plugin inactive on production until you have checked the behaviour.
Create wp-content/plugins/cn-query-category-tutorial/cn-query-category-tutorial.php. The three PHP examples belong in that one file, in the order shown. The prefix is specific to this example so it can be read alongside the existing One Base implementation without confusing the two.
1. Keep the Query Loop in charge
In the Query Loop settings, turn off inheriting the query from the template. Choose Posts as the content type and set the page size, order and any permanent editorial restrictions.
Use List View to select the Post Template inside the Query Loop. Under Advanced, add cn-filterable-articles to its additional CSS classes. This matters because the query filter receives the block building the post query. Adding the class only to the outer Query Loop will not match this example.
Keep a Query Pagination block and a No Results block inside the loop. A message such as “No articles in this category yet” gives an empty result a clear meaning. For this first version, turn off enhanced pagination if your WordPress version exposes that setting. We are checking ordinary page navigation before adding another interaction layer.
2. Read a bounded, predictable selection
Start the plugin with a small reader for the category parameter. A missing or blank selection means All. An array or a value that cannot form a slug is invalid. This is a public read-only filter, so a nonce is not needed; it does not save anything.
<?php
/**
* Plugin Name: CN Query Category Tutorial
*/
defined('ABSPATH') || exit;
function cn_tutorial_category_slug(): ?string {
if (!isset($_GET['cn-category'])) {
return '';
}
$raw = wp_unslash($_GET['cn-category']);
if (!is_string($raw) || strlen($raw) > 200) {
return null;
}
if (trim($raw) === '') {
return '';
}
$slug = sanitize_title($raw);
return $slug !== '' ? $slug : null;
}
Sanitising a value does not prove it identifies a real category. That check belongs in the next step. I prefer an invalid selection to produce no results rather than quietly show every article and make the filter appear to have worked.
3. Add a constraint to the right query
Append this filter. It checks the Post Template class, resolves a real category and adds a taxonomy condition to the existing arguments. It does not construct an unrelated second list.
add_filter('query_loop_block_query_vars', function (array $args, WP_Block $block): array {
$classes = preg_split('/\s+/', $block->parsed_block['attrs']['className'] ?? '');
if (!in_array('cn-filterable-articles', $classes, true)) {
return $args;
}
$slug = cn_tutorial_category_slug();
if ($slug === '') {
return $args;
}
$term = $slug === null ? false : get_term_by('slug', $slug, 'category');
$selected = [
'taxonomy' => 'category',
'field' => 'term_id',
'terms' => $term instanceof WP_Term ? [$term->term_id] : [0],
'include_children' => true,
];
$args['tax_query'] = empty($args['tax_query'])
? [$selected]
: ['relation' => 'AND', $args['tax_query'], $selected];
return $args;
}, 10, 2);
The nested AND is intentional. If the editor has already restricted the list, the visitor’s category narrows that selection instead of replacing it. Existing groups retain their own internal relationships. The impossible term ID of zero gives malformed or unknown selections an empty result.
I have included child categories here. Change include_children to false if your content structure needs exact matches. Keep that decision visible because it affects what readers will see.
4. Render links that work without JavaScript
The last section adds a shortcode for the controls. Its links start from the current page’s permalink, so choosing a category resets pagination to page one. This intentionally drops other query-string state: the compact example has one filter and one listing.
add_shortcode('cn_article_categories', function (): string {
$base = get_permalink(get_queried_object_id());
$terms = get_terms([
'taxonomy' => 'category',
'hide_empty' => true,
'orderby' => 'name',
'order' => 'ASC',
]);
if (!$base || is_wp_error($terms)) {
return '';
}
$selected = cn_tutorial_category_slug();
$items = ['' => 'All'];
foreach ($terms as $term) {
$items[$term->slug] = $term->name;
}
$html = '<nav aria-label="Article categories"><ul>';
foreach ($items as $slug => $label) {
$url = $slug === '' ? $base : add_query_arg('cn-category', $slug, $base);
$current = $selected === $slug ? ' aria-current="true"' : '';
$html .= '<li><a href="' . esc_url($url) . '"' . $current . '>'
. esc_html($label) . '</a></li>';
}
return $html . '</ul></nav>';
});
Activate the plugin and insert a native Shortcode block containing [cn_article_categories] above the Query Loop. The controls are links, not ARIA tabs. They navigate to another URL, so they should behave like links, including when someone opens one in a new browser tab.
Give those links a visible keyboard focus style and enough space to select comfortably. The current selection must remain understandable without relying on colour alone. One Base adds its own minimal presentation and reusable editor controls; neither is required for the query logic shown here.
Check the behaviour
Create enough test posts in one category to produce a second page. Check All, an existing category, page two of that category, an unknown slug and an array such as ?cn-category[]=development. Verify that page two keeps the selected category and that switching category returns to page one.
Also check a second Query Loop without the marker class. It should remain unchanged. Disable JavaScript, follow the category links using a keyboard, and inspect the empty state. If your page cache ignores query strings, correct its cache rules before relying on the result: a cached All page must not be served as a Development page.
Things to watch
This hook changes frontend queries. The editor preview uses the REST API and will not automatically reflect a visitor’s URL parameter. In One Base, that boundary is deliberate: editors configure the list, while visitors choose a temporary reading state.
The short example is not a multiple-listing framework. If you need two independent lists, give each its own parameter and target, or use a block with query context. One Base handles unique query IDs and URL prefixes, nested loops, pagination resets and optional dynamic navigation. Those details are where a seemingly simple filter starts needing a proper reusable component.
Finally, decide whether filtered pages are browsing states or useful search landing pages. This portfolio keeps filter states out of the index and lets the unfiltered listing paginate normally. A useful permanent topic page deserves its own introduction and curated links rather than being every possible combination of filters.