Project Category Archive Navigation with 4 Column Grid
Build a project archive with an All link, project_cat term navigation, active archive classes, and a responsive 4 column project grid using native WordPress archive queries.
Build a filterable project grid with real category archive pages and a 4-column layout — no JavaScript, no plugin needed.
Use this when you want each project category to have its own URL that search engines can crawl, instead of a JavaScript filter that keeps everyone on the same page.
Building the project category archive navigation
5 steps from an empty file to a filtered project grid. The archive file grows by one piece each step — you can reload your browser after each one and see something new.
Build a project archive with category filter links in 5 steps. Start with just the archive file, then add one piece at a time until the 4-column grid with clickable category filters is working.
Step 1: Create the archive file. WordPress automatically loads archive-projects.php when someone visits /projects/. Create that file in your theme root. For now, just loop through posts and output the title — nothing else yet:
<?php
/**
* File: archive-projects.php
* WordPress loads this automatically when someone visits /projects/.
*/
get_header();
?>
<main>
<?php while ( have_posts() ) : the_post(); ?>
<h2>
<a href="<?php the_permalink(); ?>"><?php the_title(); ?></a>
</h2>
<?php endwhile; ?>
</main>
<?php
get_footer();
Step 2: Declare your slugs as variables. You will use the post type slug and taxonomy slug in several places. Naming them once at the top means you only update one line if your slugs are different:
<?php
// Your CPT slug — matches the first argument in register_post_type().
$post_type = 'projects';
// Your taxonomy slug — matches the first argument in register_taxonomy().
$taxonomy = 'project_cat';
get_header();
?>
<main>
<?php while ( have_posts() ) : the_post(); ?>
<h2>
<a href="<?php the_permalink(); ?>"><?php the_title(); ?></a>
</h2>
<?php endwhile; ?>
</main>
<?php
get_footer();
Step 3: Add the “All” link. get_post_type_archive_link() returns the archive URL. is_post_type_archive() returns true only when you are currently on that page — that is how PHP decides whether to add the is-active class:
<?php
$post_type = 'projects';
$taxonomy = 'project_cat';
$archive_url = get_post_type_archive_link( $post_type );
$on_all_page = is_post_type_archive( $post_type );
get_header();
?>
<main>
<nav class="project-filter">
<a href="<?php echo esc_url( $archive_url ); ?>"
class="project-filter__link <?php echo $on_all_page ? 'is-active' : ''; ?>">
All
</a>
</nav>
<?php while ( have_posts() ) : the_post(); ?>
<h2>
<a href="<?php the_permalink(); ?>"><?php the_title(); ?></a>
</h2>
<?php endwhile; ?>
</main>
<?php
get_footer();
Step 4: Add one link per category. get_terms() fetches all categories for your taxonomy. get_queried_object() returns the term currently being viewed — so PHP can compare each term’s ID against it and add is-active to only the right link:
<?php
$post_type = 'projects';
$taxonomy = 'project_cat';
$archive_url = get_post_type_archive_link( $post_type );
$on_all_page = is_post_type_archive( $post_type );
$terms = get_terms( [ 'taxonomy' => $taxonomy, 'hide_empty' => true ] );
$current_term = get_queried_object();
$on_term_page = is_tax( $taxonomy ) && $current_term instanceof WP_Term;
get_header();
?>
<main>
<nav class="project-filter">
<a href="<?php echo esc_url( $archive_url ); ?>"
class="project-filter__link <?php echo $on_all_page ? 'is-active' : ''; ?>">
All
</a>
<?php foreach ( $terms as $term ) : ?>
<?php $is_active = $on_term_page && $current_term->term_id === $term->term_id; ?>
<a href="<?php echo esc_url( get_term_link( $term ) ); ?>"
class="project-filter__link <?php echo $is_active ? 'is-active' : ''; ?>">
<?php echo esc_html( $term->name ); ?>
</a>
<?php endforeach; ?>
</nav>
<?php while ( have_posts() ) : the_post(); ?>
<h2>
<a href="<?php the_permalink(); ?>"><?php the_title(); ?></a>
</h2>
<?php endwhile; ?>
</main>
<?php
get_footer();
Step 5: Replace the title list with a card grid. One change inside the loop — wrap each post in an article with a thumbnail and excerpt. The nav above stays exactly the same:
<?php
$post_type = 'projects';
$taxonomy = 'project_cat';
$archive_url = get_post_type_archive_link( $post_type );
$on_all_page = is_post_type_archive( $post_type );
$terms = get_terms( [ 'taxonomy' => $taxonomy, 'hide_empty' => true ] );
$current_term = get_queried_object();
$on_term_page = is_tax( $taxonomy ) && $current_term instanceof WP_Term;
get_header();
?>
<main>
<nav class="project-filter">
<a href="<?php echo esc_url( $archive_url ); ?>"
class="project-filter__link <?php echo $on_all_page ? 'is-active' : ''; ?>"
<?php echo $on_all_page ? 'aria-current="page"' : ''; ?>>
All
</a>
<?php foreach ( $terms as $term ) : ?>
<?php $is_active = $on_term_page && $current_term->term_id === $term->term_id; ?>
<a href="<?php echo esc_url( get_term_link( $term ) ); ?>"
class="project-filter__link <?php echo $is_active ? 'is-active' : ''; ?>"
<?php echo $is_active ? 'aria-current="page"' : ''; ?>>
<?php echo esc_html( $term->name ); ?>
</a>
<?php endforeach; ?>
</nav>
<div class="project-grid">
<?php while ( have_posts() ) : the_post(); ?>
<article class="project-card">
<a class="project-card__media" href="<?php the_permalink(); ?>">
<?php if ( has_post_thumbnail() ) : ?>
<?php the_post_thumbnail( 'medium_large' ); ?>
<?php endif; ?>
</a>
<div class="project-card__body">
<h2 class="project-card__title">
<a href="<?php the_permalink(); ?>"><?php the_title(); ?></a>
</h2>
<?php if ( has_excerpt() ) : ?>
<p><?php echo esc_html( get_the_excerpt() ); ?></p>
<?php endif; ?>
</div>
</article>
<?php endwhile; ?>
</div>
</main>
<?php
get_footer();
That is your complete archive-projects.php. For category archive pages WordPress also needs taxonomy-project_cat.php. You can copy the same file there, or use get_template_part() in both files to share one template and keep a single copy of the code. Section 03 shows the shared template approach.
How project category archive navigation works
The active class comes from WordPress conditional tags, not JavaScript.
Why the All link needs a different conditional than the category links
The All link points to the main post type archive, and the category links each point to a taxonomy term archive. WordPress treats these as two different types of pages. is_post_type_archive() returns true only on the main archive URL. is_tax() returns true only on a taxonomy term URL. Checking them separately lets you highlight exactly one link at a time — whichever page you are currently on.
Why each category gets its own URL instead of a JavaScript filter
A JavaScript filter hides and shows posts without changing the URL. That means every category shares the same page — visitors cannot bookmark a specific category, and search engines cannot index each category separately. Using get_term_link( $term ) gives each category its own real URL. WordPress handles the filtering automatically when it loads taxonomy-project_cat.php.
Why you should not replace the main query with a custom WP_Query
On archive and taxonomy templates, WordPress already runs the right query before your template file loads. On archive-projects.php it queries all published projects. On taxonomy-project_cat.php it automatically filters down to just the projects in the selected category. If you add a new WP_Query on top, two queries run — the default one is still there, just ignored. Sticking with have_posts() keeps pagination, Rank Math titles, and canonical URLs working correctly without any extra setup.
Why real category URLs are better for SEO than a JavaScript filter
When every category lives on the same URL, search engines see one page with mixed content and cannot tell the categories apart. With real taxonomy archive URLs, each category page such as /project-category/branding/ is a separate, crawlable page that can have its own title, description, canonical URL, and term description. Each category becomes a focused topic instead of a hidden filter state.
Project category archive navigation setup
Create two templates, one shared partial, and make sure your taxonomy is registered for the projects post type.
Create the template files
Create archive-projects.php, taxonomy-project_cat.php, and template-parts/project-archive-grid.php. If your CPT slug is singular, rename the archive file to archive-project.php.
Register or confirm the taxonomy
Your taxonomy must be attached to the projects post type. If it already exists, you do not need this code. If you are starting fresh, paste this in functions.php.
<?php
// Paste the code below into your theme's functions.php.
// Do NOT copy the <?php line above - your functions.php already has one.
add_action( 'init', function() {
register_taxonomy( 'project_cat', array( 'projects' ), array(
'labels' => array(
'name' => 'Project Categories',
'singular_name' => 'Project Category',
),
'public' => true,
'hierarchical' => true,
'show_in_rest' => true,
'rewrite' => array(
'slug' => 'project-category',
),
) );
} );
Flush permalinks once
Go to Settings -> Permalinks and click Save Changes. This refreshes WordPress rewrite rules so the archive and taxonomy URLs stop returning 404s.
Add the CSS
Paste the CSS into your theme stylesheet, then adjust colors and spacing to match your design.
.project-filter {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin: 32px 0;
}
.project-filter__link {
border: 1px solid #d8d8d8;
border-radius: 999px;
color: #333;
padding: 8px 14px;
text-decoration: none;
}
.project-filter__link.is-active {
background: #111;
border-color: #111;
color: #fff;
}
.project-grid {
display: grid;
gap: 24px;
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.project-card__media {
aspect-ratio: 4 / 3;
background: #f3f3f3;
display: block;
overflow: hidden;
}
.project-card__media img {
height: 100%;
object-fit: cover;
width: 100%;
}
@media (max-width: 1024px) {
.project-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 640px) {
.project-grid {
grid-template-columns: 1fr;
}
}
Getting 404s after adding your template files? Go to Settings → Permalinks and click Save Changes. WordPress rebuilds its rewrite rules at that point — without it, new archive and taxonomy URLs return 404.
Wrong posts showing on the archive
Check that your CPT slug matches the template filename exactly. If the post type is registered as project, the archive file must be archive-project.php — not archive-projects.php. WordPress uses the exact slug to pick the template file.
Category filter links return 404
The taxonomy must be registered and attached to the correct post type before its term archive URLs work. If you registered the taxonomy after creating posts, go to Settings → Permalinks → Save Changes once more. Also confirm the taxonomy rewrite slug matches what you expect in the URL.
Active class not appearing on filter links
The active class relies on is_post_type_archive() and is_tax(). These only return true inside the correct archive template. If you are loading the navigation from a sidebar or widget outside the archive context, the conditionals will always be false. Keep the navigation inside the archive template, or pass the current archive state as a variable.
Customizing your project category archive navigation
A few small changes make this fit most project archives.
Use a different CPT slug
Change $post_type = "projects"; to your real post type slug. Then rename the archive template to match. WordPress uses archive-{post_type}.php.
Show empty terms while building
During development, change hide_empty to false so categories appear even before projects are assigned. Switch it back to true before launch.
Control posts per page
Use WordPress Reading settings for the simplest setup, or use pre_get_posts if this archive needs a different number from the blog. For a 4 column grid, 8, 12, or 16 posts per page usually feels balanced.
Do not replace the main query with a new WP_Query unless you need to. A custom query can break pagination and confuse SEO plugins if it is not wired carefully. Archive templates already have the right query.
SEO tip — write a description for each category. Go to Projects → Project Categories in your WordPress admin. Click any category, fill in the Description field, and save. The template already prints this on the term archive page via term_description(), giving each category page unique content that search engines can index independently. Useful references: WordPress template hierarchy, get_terms(), project archive example, and more WordPress snippets.
No comments yet. Be the first.