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 just for this feature, so we developed a custom solution.

Part 1: setting Primary Categories

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

Like many block editor enhancements, this script requires a build process and 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', 'hp_pc_enqueue_block_editor_assets' );
/**
 * Enqueue this plugin's script in the block editor.
 */
function hp_pc_enqueue_block_editor_assets() {
	$asset_data = require_once __DIR__ . '/build/index.asset.php';

	wp_enqueue_script(
		'primary-category',
		plugins_url( '/build/index.js', __FILE__ ),
		$asset_data['dependencies'],
		$asset_data['version'],
		true
	);
}

add_action( 'init', 'hp_pc_register_primary_category_meta' );
/**
 * Register the _primary_category meta field.
 */
function hp_pc_register_primary_category_meta() {
	register_meta(
		'post',
		'_primary_category',
		array(
			'show_in_rest' => true,
			'single'       => true,
			'type'         => 'integer',
			'auth_callback' => function() {
				return current_user_can( 'edit_posts' );
			},
		)
	);
}
Code language: PHP (php)

Since most of our code will be JavaScript, 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.

Next, we need to add a package.json file that will handle building the final JavaScript:

{
	"name": "primary-category-permalinks",
	"version": "0.0.1",
	"description": "Adds a Primary Category selector to Posts.",
	"devDependencies": {
		"@wordpress/scripts": "^26.10.0"
	},
	"scripts": {
		"build": "wp-scripts build",
		"lint": "wp-scripts lint-js",
		"fix": "wp-scripts lint-js --fix"
	}
}
Code language: JSON / JSON with Comments (json)

The package requires just one development dependency, WordPress Scripts, and adds commands for building and linting the plugin’s JavaScript.

Head to the command line, go to your /wp-content/plugins/primary-category-permalinks/ folder, and enter npm install to download the @wordpress/scripts dependency.

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 { store as coreStore } from '@wordpress/core-data';
import { dispatch, useSelect, withSelect } from '@wordpress/data';
import { store as editorStore } from '@wordpress/editor';
import { addFilter } from '@wordpress/hooks';
import { decodeEntities } from '@wordpress/html-entities';

function addPrimaryCategoryControl( OriginalComponent ) {
	return ( props ) => {
		if ( 'category' !== props.slug ) {
			return <OriginalComponent { ...props } />;
		}

		const { terms } = useSelect( ( select ) => {
			const { getEditedPostAttribute } = select( editorStore );
			const taxonomy = select( coreStore ).getTaxonomy( props.slug );

			return {
				terms: taxonomy
					? getEditedPostAttribute( taxonomy.rest_base )
					: [],
			};
		} );

		const PrimaryCategoryControl = withSelect( ( select ) => {
			const categoryOptions = [];
			if ( terms ) {
				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 (
				! terms.length ||
				! categoryOptions.length ||
				categoryOptions.length !== 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 ( ! terms.find( ( t ) => t === primaryCategory ) ) {
				selectFirstAvailable();
			}
			return (
				<>
					<h3>Primary category</h3>
					<RadioControl
						selected={ primaryCategory }
						options={ categoryOptions }
						onChange={ ( option ) =>
							dispatch( 'core/editor' ).editPost( {
								meta: { _primary_category: Number( option ) },
							} )
						}
					/>
				</>
			);
		} );

		return (
			<>
				<OriginalComponent { ...props } />
				<PrimaryCategoryControl />
			</>
		);
	};
}

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

Code language: JavaScript (javascript)

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 post meta 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 to use the primary category in the post’s permalink rather than the first category:

add_filter( 'post_link_category', 'hp_pc_use_primary_category', 10, 3 );
/**
 * Filter the category used in a post's permalink.
 *
 * @param WP_Term   $category   The category to use in the permalink.
 * @param WP_Term[] $categories Array of all categories associated with the post.
 * @param WP_Post   $post       The post in question.
 * @return WP_Term The category to use in the permalink.
 */
function hp_pc_use_primary_category( $category, $categories, $post ) {
	$primary_category = get_post_meta( $post->ID, '_primary_category', true );

	if ( $primary_category && term_exists( (int) $primary_category ) ) {
		$category = get_term( $primary_category );
	}

	return $category;
}
Code language: PHP (php)

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', 'hp_pc_redirect_to_primary_category' );
/**
 * Redirect a request for a post with multiple categories to a
 * more canonical URL.
 */
function hp_pc_redirect_to_primary_category() {

	if ( is_single() ) {
		// Retrieve the requested post's categories.
		$categories = wp_get_post_categories( get_queried_object_id() );

		// If more than one category is assigned, find the primary.
		if ( ! is_wp_error( $categories ) && count( $categories ) > 1 ) {
			$category_id = get_post_meta( get_queried_object_id(), '_primary_category', true );

			// If a primary category isn't set, use the first category.
			if ( ! $category_id ) {
				$category_id = $categories[0];
			}

			$primary_category = get_term( $category_id );

			if ( ! $primary_category || is_wp_error( $primary_category ) ) {
				return;
			}

			// If the primary category's slug isn't in the URL, redirect.
			if ( false === strpos( $_SERVER['REQUEST_URI'], $primary_category->slug ) ) {
				$primary_url = home_url( '/' . $primary_category->slug . '/' . get_queried_object()->post_name . '/' );

				wp_safe_redirect( $primary_url, 301 );
				exit;
			}
		}
	}
}
Code language: PHP (php)

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

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.

7 Comments

If you’re using Yoast, you’ll just need to change the postmeta key in the call to `get_post_meta()`. Change `_primary_category` to `_yoast_wpseo_primary_category` – the key Yoast SEO uses. You can change this in both the step 2 Use in URLs code and the step 3 Automatic Redirects steps to have the full functionality.

Hi Sassi, I’m sorry it isn’t working for you. What version of WP are you running? Can you explain a little more about what you mean by “select two categories?” Are you being prevented from assigning more than one category to your post?

Leave a Reply to Sassi Cancel reply