Using a primary category in WP permalinks

Many SEO experts recommend using your category in your post permalinks, as it gives another hint of context to both search engines and human visitors. But what happens when you assign more than one category per post?

Option 1: Core behavior

Let’s say you start out with a /%category%/%postname%/ structure already in place.

You publish a new post titled Apples, and you place it in three categories: Fruit, Health Food, and Red Things.

When you hit Publish, WordPress picks the category with the lowest ID to use in the permalink. In this case, you added the Health Food category a year before you created your Fruit category, so Health Food has an ID of 12 and Fruit’s ID is 33. Red Things’ ID is 27.

Since you picked all of them, WordPress will choose the lowest ID – 12, making your permalink https://example.com/health-food/apples/. This is the link that will appear in your sitemap.

No control

Your post focuses on apples as a fruit, so you’d prefer the post to show up in your sitemap using the Fruit category instead. There’s no mechanism in Core to handle that.

Duplicate content

But there’s another problem: since your post is in three categories, it actually has three working permalinks. All of these work and render the same content:

  • https://example.com/health-food/apples/
  • https://example.com/red-things/apples/
  • https://example.com/fruit/apples/

Only the Health Food link shows up in your sitemap, but if you choose to share the Fruit link on social media, suddenly you’re getting visits on multiple URLs to the exact same content. This splits their view in analytics and makes it harder for search engines to tell which one you meant to index.

Option 2: using Yoast SEO

Since using categories in permalinks is good for SEO, and assigning multiple categories per post is common, Yoast SEO handles part of your problem out of the box.

After activating the plugin, you edit your Apples post. Now underneath the category selection, you see a Primary Category dropdown. You choose Fruit and hit Update, but you’re surprised to see health-food still in the URL when you view it. What’s happening here?

Yoast doesn’t add your primary category to the Core permalink. Instead, it handles this situation by:

  • Using the Primary Category in your sitemap. Search engines and visitors who view your XML sitemap will see your preferred URL, https://example.com/fruit/apples/.
  • Setting canonical links on all versions. All 3 URLs tell search engines your preferred URL, so it’s likely (but not guaranteed) Google will show your Fruits URL in search results.

So, you’re better off than you were before, but if you’d still like to get that one primary URL set as WP Core’s permalink and actually redirect the other versions instead of just adding a canonical, keep reading.

Option 3: using a custom plugin

It always surprises me when a common problem requires custom code, so I hope this solution is helpful!

We recently worked on a site facing the multi-category permalink challenge. They really wanted the Core permalink to use the Primary Category, and they also wanted their other-category links to 301 redirect to the primary rather than just having a canonical. That way, the primary link would be used everywhere: search engine results, analytics reports, end users’ address bars.

This site wasn’t initially using Yoast SEO, and although it’s a great plugin, it would have added a lot of extra functionality and bloat just for this feature, so we developed a custom solution.

Part 1: setting Primary Categories

Without Yoast’s dropdown, we needed a way for this site’s editors to select a primary category. To add it right below the Core category selection UI, we added a filter.

Like many Block Editor enhancements, this script requires a build process, and it works well as a plugin – that way when you change themes, you’re not losing functionality.

Let’s create a folder on a local site: /wp-content/plugins/primary-category-permalinks/. Inside that folder, let’s add a plugin.php file:

<?php
/**
 * Plugin Name: Primary Category Permalinks
 * Description: Adds a Primary Category selector to Posts.
 */

add_action( 'enqueue_block_editor_assets', 'enqueue_block_editor_assets' );
function enqueue_block_editor_assets() {
	wp_enqueue_script(
		'primary-category',
		plugins_url( '/build/primary-category.js', __FILE__ ),
		array( 'wp-components' ),
		'0.0.1',
		true
	);
}

Since most of our code will be JS, the plugin file is just a stub with a comment that lets WordPress recognize it as a plugin, and a command to enqueue the script we’ll need in the Block Editor.

Since we’ll be using ESNext, we need to add a package.json file that will handle building the final JS:

{
	"name": "primary-category-permalinks",
	"version": "0.0.1",
	"description": "Adds a Primary Category selector to Posts.",
	"dependencies": {
		"@wordpress/scripts": "^18.0.1"
	},
	"scripts": {
		"build": "wp-scripts build src/*.js --output-path=build/"
	}
}

The package requires just one dependency, WordPress Scripts, and creates just one command. Head over to the command line with Node and npm installed, go to your /wp-content/plugins/primary-category-permalinks/ folder, and enter npm install to download WordPress Scripts.

Next we’ll add the main code – the JavaScript that actually creates our custom control and puts it in the Block Editor. Let’s name this file /wp-content/plugins/primary-category-permalinks/src/index.js:

// WordPress dependencies.
import { RadioControl } from '@wordpress/components';
import { dispatch, withSelect } from '@wordpress/data';
import { addFilter } from '@wordpress/hooks';
import { decodeEntities } from '@wordpress/html-entities';

function addPrimaryCategoryControl( OriginalComponent ) {
	return ( props ) => {
		if ( 'category' !== props.slug ) {
			return <OriginalComponent { ...props } />;
		}
		const PrimaryCategoryControl = withSelect( ( select ) => {
			const categoryOptions = [];
			if ( props.terms ) {
				props.terms.forEach( ( categoryId ) => {
					const category = select( 'core' ).getEntityRecord( 'taxonomy', 'category', categoryId );

					if ( category ) {
						categoryOptions.push( {
							label: decodeEntities( category.name ),
							value: Number( categoryId ),
						} );
					}
				} );
			}
			return {
				categoryOptions,
				primaryCategory: select( 'core/editor' ).getEditedPostAttribute( 'meta' )._primary_category,
			};
		} )( ( { categoryOptions, primaryCategory } ) => {
			// Return early if the post doesn't have any categories currently selected.
			if ( ! props.terms.length || ! categoryOptions.length || categoryOptions.length !== props.terms.length ) {
				return null;
			}
			const selectFirstAvailable = () => {
				const value = Number( categoryOptions[ 0 ].value );
				dispatch( 'core/editor' ).editPost( { meta: { _primary_category: value } } );
			};
			// If no primary category set is set,
			// or the current primary category is not among the selected categories,
			// select the first available category.
			if ( 0 === primaryCategory ) {
				// This will also set the default category (typically "Uncategorized")
				// as the Primary on save if the post has no other categories set.
				selectFirstAvailable();
			} else if ( ! props.terms.find( ( t ) => t === primaryCategory ) ) {
				selectFirstAvailable();
			}
			return (
				<div style={ {
					marginTop: '1em',
				} }>
					<h3>Primary category</h3>
					<div style={ {
						margin: '-6px 0 0 -6px',
						maxHeight: '10.5em',
						overflow: 'auto',
						padding: '6px 0 2px 6px',
					} } >
						<RadioControl
							selected={ primaryCategory }
							options={ categoryOptions }
							onChange={ ( option ) =>
								dispatch( 'core/editor' ).editPost( {
									meta: { _primary_category: Number( option ) },
								} )
							}
						/>
					</div>
				</div>
			);
		} );
		return (
			<>
				<OriginalComponent { ...props } />
				<PrimaryCategoryControl />
			</>
		);
	};
}

addFilter(
	'editor.PostTaxonomyType',
	'happyprime/add-primary-category-control',
	addPrimaryCategoryControl
);

To transpile this JS, head back to your command line in /wp-content/plugins/primary-category-permalinks/ and enter npm run build. You can now activate the plugin and try it out.

Part 2: using Primary Categories in URLs

Once the Primary Category postmeta exists, it takes very little code to tell WordPress to use it in post permalinks. It took awhile to work this out, and I want to thank Hikari for his helpful plugin Hikari Category Permalink which explained, with detailed comments, how he had solved this problem.

We didn’t use his plugin directly because it has fallbacks for much older versions of WP, but reading the code was instrumental in developing our shorter plugin.

Back in plugin.php, these few lines tell WordPress exactly how to use our Primary Categories:

add_filter( 'post_link_category', 'use_primary_category', 10, 3 );
function use_primary_category( $cat, $cats, $post ) {
	$primary_category = get_post_meta( $post->ID, '_primary_category', true );
	if ( $primary_category && term_exists( (int) $primary_category ) ) {
		$cat = get_term( $primary_category );
	}
	return $cat;
}

We’re using the post_link_category hook, which specifically targets the /%category%/ placeholder. When WordPress encounters the placeholder, it now knows to look for our _primary_category postmeta. If it doesn’t exist, it can fall back to Core behavior because we always return a category in this function, whether it’s our Primary Category or just the initial one.

Part 3: setting up automatic 301 redirects

The icing on the cake, which makes sure all of the analytics for a particular post go through a single permalink, is setting up redirects from those other categories. Right now, even though WordPress is using our Fruit category in our primary permalink, Core behavior dictates that all of these links will still work and render the same content:

  • https://example.com/health-food/apples/
  • https://example.com/red-things/apples/
  • https://example.com/fruit/apples/

Without Yoast SEO to set canonicals, we’re not telling search engines to prefer any one over the other. Google might index the Red Things URL just because more people happen to link to that version.

Another short snippet to place in our functions.php will wrap things up:

add_action( 'template_redirect', 'redirect_to_primary_category' );
function redirect_to_primary_category() {
	global $post;
	// If the post is single.
	if ( is_single( $post->ID ) ) {
		// If the post has multiple categories assigned.
		$cats = wp_get_post_categories( $post->ID );
		if ( count( $cats ) > 1 ) {
			// Get the primary category.
			$primary_category = get_post_meta( $post->ID, '_primary_category', true );
			// If the primary category isn't in the URL, redirect.
			global $wp;
			$primary_object = get_term( $primary_category );
			if ( 0 !== strpos( $wp->request, $primary_object->slug ) ) {
				$primary_url = home_url( '/' . $primary_object->slug . '/' . $post->post_name . '/' );
				wp_safe_redirect( $primary_url, 301 );
				exit;
			}
		}
	}
}

This snippet has more conditionals, because we’re drilling down slowly to just the posts we want to affect. First, we make sure the post we’re looking at is single, not an archive or homepage. Next, we check to see if multiple categories are assigned. Finally, we check whether the post has _primary_category postmeta and see whether it’s already in the URL. If not, we 301 redirect to that primary-category URL.

I would love to see Primary Category functionality added to Core at some point, since it seems to be a common need. But for now, adding a lightweight plugin solves both the SEO problem and the analytics conundrum of having multiple links pointing to the same content.

Leave a Reply