search ]

Building a Custom Link Structure using the WordPress Rewrite API

One of the important and less known APIs in WordPress is the Rewrite API. This API provides the ability and functionality to create a custom link structure.

In the first part of this post, we will learn how to use the default Query Vars that WordPress provides and see how to add our own custom variables.

In the second part of the post, we will explore the tools WordPress provides us to convert a query structure to a fixed link structure (Permalink) using the Rewrite API interface.

Introduction: URL Structure

URLs are used to send HTTP GET requests over the internet. The GET method sends pairs of key = value within the URL to receive a response from a specific server.

Take, for example, the following website address and pay attention to the last part of this URL:

http://example.com/?p=123

The question mark (?) divides this website address into two parts. The first part is the domain name, and the second part is the Query String.

While WordPress knows that it should fetch the post with ID number 123, it is not semantic or readable for humans, meaning the address does not give us any information about its content.

This situation is not ideal for users and does not contribute to SEO and search engine rankings. Therefore, we have the option to translate non-semantic addresses into semantic addresses using the WordPress Rewrite API.

Using this rewrite, for example, we can translate the previous URL into the following structure:

http://example.com/category/post-title/

Now the address is much more understandable. We can see the category name and the post title within the URL. This structure describes the content of the address more accurately for both humans and search engines, resulting in a semantic, accessible, and SEO-friendly URL.

You can add the address to bookmarks, share it in multiple ways, and it should not change over time.

This is why we call them Permalinks (Permanent Links).

But before we see how to rewrite a query-format link into a permalink structure, let’s learn more about Query Strings and Query Vars.

Part 1: What are Query Vars and Query Strings?

WordPress can be asked to retrieve almost anything from the site’s database. For example, if we want all posts from a specific category, all posts tagged with a specific tag, or even to get a post published on a specific date.

When a user sends a URL, WordPress handles that request according to the template hierarchy rules and displays the results in a single page or an archive page.

Public Query Vars vs. Private Query Vars

Query Vars are the keys that define any query WordPress executes to retrieve information from the database. These variables are divided into two categories:

  • Public Query Vars – Variables in the URL intended for use in a query.
  • Private Query Vars – Variables intended for use only through code.

Let’s take an example URL:

example.com/?author_name=moshe

In this example, the Query Var is author_name, instructing WordPress to provide us with all posts written by the user named moshe. It’s worth noting that multiple variables can be added using the & sign. For example:

example.com/?author_name=moshe&tag=news

In this case, WordPress will provide us with all posts by moshe tagged with news.

In the following example, we see a query for a custom content type (Custom Post Type) named “food,” with a custom taxonomy named food-family, where the term of the taxonomy is greens:

example.com/?post_type=food&food-family=greens

Unlike Public Query Vars, Private Query Vars can only be used within a PHP query. This post will not cover them, but you can find the WP_Query parameters including both types of Query Vars in the official documentation.

Now, let’s take a look at all the built-in variables (of the type Public Query Vars) that WordPress provides us:

<?php
$keys = array(
	'error', 
	'm', 
	'p', 
	'post_parent', 
	'subpost', 
	'subpost_id', 
	'attachment', 
	'attachment_id', 
	'name', 
	'static', 
	'pagename', 
	'page_id', 
	'second', 
	'minute', 
	'hour', 
	'day', 
	'monthnum', 
	'year', 
	'w', 
	'category_name', 
	'tag', 
	'cat', 
	'author', 
	'author_name', 
	'feed', 
	'tb', 
	'paged', 
	'comments_popup',
	'preview', 
	's', 
	'sentence', 
	'title', 
	'fields', 
	'menu_order'
);

We can retrieve posts by content type, author, category, tag, taxonomy, year, month, day, and so on. WordPress provides us with variables for almost every type of query, as mentioned. However, what is missing from this list is the ability to build queries based on custom fields.

In fact, WordPress provides us with variables named meta_key and meta_value, but these are not of the Public type suitable for queries in the URL. These are Private variables that can only work within code, meaning through WP_Query and not as part of a URL query. Let’s see how to create new Query Vars…

Creating Custom Query Vars

After adding our own variables, they can coexist within a query just like all the built-in Query Vars of WordPress. So let’s say we have a custom content type for books named book, to which we added a custom field named author_surname indicating the book’s author.

If we want to create a query that finds books based on that author_surname, we need to create a new Query Var. The function below shows how to add new Custom Query Vars to WordPress’s list. In our case, we’ll add a Query Var named book-author. Here’s how:

<?php
/**
 * Register custom query vars
 *
 * @param array $vars The array of available query variables
 *
 * @link https://developer.wordpress.org/reference/hooks/query_vars/
 */
function myplugin_register_query_vars( $vars ) {
	$vars[] = 'book-author';
	return $vars;
}
add_filter( 'query_vars', 'myplugin_register_query_vars' );

In the next step, we need to inform WordPress that it should retrieve the value of that specific Query Var when executing the query. This is done using the WordPress function called get_query_var.

To accomplish this, we will create our own function and attach it to a hook named pre_get_posts that occurs just before WordPress “fetches” the posts for us. The function in question looks like this:

<?php
/**
 * Build a custom query
 *
 * @param $query obj The WP_Query instance (passed by reference)
 *
 * @link https://developer.wordpress.org/reference/hooks/pre_get_posts/
 */
function myplugin_pre_get_posts( $query ) {
	// check if the user is requesting an admin page 
	// or current query is not the main query
	if ( is_admin() || ! $query->is_main_query() ){
		return;
	}

	// edit the query only when post type is 'book'
	// if it isn't, return
	if ( !is_post_type_archive( 'book' ) ){
		return;
	}

	$meta_query = array();

	// add meta_query elements
	if( !empty( get_query_var( 'book-author' ) ) ){
		$meta_query[] = array( 'key' => 'author_surname', 'value' => sanitize_text_field( get_query_var( 'book-author' ) ), 'compare' => 'LIKE' );
	}

	if( count( $meta_query ) > 1 ){
		$meta_query['relation'] = 'AND';
	}

	if( count( $meta_query ) > 0 ){
		$query->set( 'meta_query', $meta_query );
	}
}
add_action( 'pre_get_posts', 'myplugin_pre_get_posts', 1 ); 

Notice we added sanitize_text_field() around the Query Var value. Since these values come directly from the URL, always sanitize user input to prevent security issues such as SQL Injection or XSS.

In the first part of the function, we check if we are in the admin interface – if so, the function will stop and not return anything. The same applies to a situation where we are not in the main loop of WordPress, to avoid issues with other loops.

Another thing we need to ensure is that we are on the archive page of the content type book, otherwise, we won’t return anything.

After these checks, we create a meta query that will retrieve only posts with the author_surname field (this is the key), and it has a value equal to the value entered for the query var named book-author.

Next, we check if there is more than one item in the array (meaning there is another parameter besides our variable), and if so, we add an AND relation to the array.

The last step is to insert the $meta_query array into the query object, and we do this using the set() method of the WP_Query class. Essentially, we take the value from the URL, then create a regular WP_Query with that value.

Now, after entering the following URL, WordPress will provide us with all posts of the content type book that have a field named author_surname equal to “Moshe”:

http://example.com/?post_type=book&book-author=Moshe

Looks great, but one more thing remains to be done. We want the URL to be in the format of a permalink rather than a raw query string. It should look like this:

http://example.com/book/book-author/Moshe/

For this purpose, we need to tell WordPress to translate (rewrite) the previous URL structure (in query format) into the format of a permanent link, and here comes the use of the Rewrite API of WordPress.

Part 2: Rewriting Links – Rewrite API

Before we see how to perform the rewrite using the Rewrite API, let’s take a brief look at the Permalink Structure in WordPress.

WordPress Permalink Structure

WordPress allows us to choose from several different default permalink structures, but we can also create our own custom permalink structure using various tags that WordPress provides by default. For example, year (%year%), post ID (%post_id%), post author (%author%), and more.

However, beyond that, we can add our own custom tags. Here comes into play the process of rewriting, which is divided into two actions.

Rewrite Tags & Rewrite Rules

The first action is adding a tag (Rewrite tag) for the URL. The second action is adding a rule (Rewrite rule) that links the tag to the query variable (Query Vars). In our case, we will add a tag for the same Query Var named book-author that we created, and we will do this using the add_rewrite_tag function.

<?php
/**
 * Add rewrite tags
 *
 * @link https://developer.wordpress.org/reference/functions/add_rewrite_tag/
 */
function myplugin_rewrite_tag() {
	add_rewrite_tag( '%book-author%', '([^&]+)' );
}
add_action('init', 'myplugin_rewrite_tag', 10, 0);

Now all that’s left is to tell WordPress to associate the custom tag we added with the Query Var we created named book-author. We do this using the WordPress function for creating rewrite rules called add_rewrite_rule.

<?php
/**
 * Add rewrite rules
 *
 * @link https://developer.wordpress.org/reference/functions/add_rewrite_rule/
 */
function myplugin_rewrite_rule() {
	add_rewrite_rule( '^book/book-author/([^/]*)/?', 'index.php?post_type=book&book-author=$matches[1]','top' );
}
add_action('init', 'myplugin_rewrite_rule', 10, 0);

Now, the URL:

http://example.com/?post_type=book&book-author=Moshe/

Will be theoretically identical and provide exactly the same result as the following URL:

http://example.com/book/book-author/Moshe/

WordPress translates (rewrites) this URL to the previous one and then executes the mentioned query.

It is crucial to mention that after adding new Rewrite Tags or Rewrite Rules, you must go to the WordPress admin interface > Settings > Permalinks and save the changes, otherwise the modifications will not take effect.

Flushing Rewrite Rules Programmatically

If you are writing a plugin and want to flush rewrite rules automatically without requiring the user to manually visit the Permalinks settings, use the flush_rewrite_rules() function but only inside the plugin’s activation hook (register_activation_hook):

<?php
register_activation_hook( __FILE__, 'myplugin_activate' );

function myplugin_activate() {
	myplugin_rewrite_tag();
	myplugin_rewrite_rule();
	flush_rewrite_rules( false );
}

Never call flush_rewrite_rules() inside the init hook or on every page load. This function writes to the database and can cause significant performance degradation. The correct approach is to call it once during plugin activation, and again in register_deactivation_hook to clean up the rules when the plugin is deactivated.

For your convenience, here is a plugin that I created to perform the actions in this guide, including creating the custom content type. I hope this guide helped you understand more about the power of the WordPress Rewrite API and how it can be beneficial.

<?php
/*
Plugin Name: Custom Query Vars
*/

// Our custom post type function
function create_posttype_book()
{

    register_post_type('book',
        // CPT Options
        array(
            'labels' => array(
                'name' => __('Books'),
                'singular_name' => __('Book')
            ),
            'public' => true,
            'has_archive' => true,
            'rewrite' => array('slug' => 'book'),
            'show_in_rest' => true,
            'supports' => array('title', 'editor', 'author', 'thumbnail', 'custom-fields')

        )
    );
}

// Hooking up our function to theme setup
add_action('init', 'create_posttype_book');


/**
 * Register custom query vars
 *
 * @param array $vars The array of available query variables
 *
 * @link https://developer.wordpress.org/reference/hooks/query_vars/
 */
function myplugin_register_query_vars($vars)
{
    $vars[] = 'book-author';
    return $vars;
}

add_filter('query_vars', 'myplugin_register_query_vars');


/**
 * Build a custom query
 *
 * @param $query obj The WP_Query instance (passed by reference)
 *
 * @link https://developer.wordpress.org/reference/hooks/pre_get_posts/
 */
function myplugin_pre_get_posts($query)
{
    // check if the user is requesting an admin page
    // or current query is not the main query
    if (is_admin() || !$query->is_main_query()) {
        return;
    }

    // edit the query only when post type is 'book'
    // if it isn't, return
    if (!is_post_type_archive('book')) {
        return;
    }

    $meta_query = array();

    // add meta_query elements
    if (!empty(get_query_var('book-author'))) {
        $meta_query[] = array(
            'key' => 'author_surname',
            'value' => sanitize_text_field( get_query_var('book-author') ),
            'compare' => 'LIKE'
        );
    }

    if (count($meta_query) > 1) {
        $meta_query['relation'] = 'AND';
    }

    if (count($meta_query) > 0) {
        $query->set('meta_query', $meta_query);
    }
}

add_action('pre_get_posts', 'myplugin_pre_get_posts', 1);


/**
 * Add rewrite tags
 *
 * @link https://developer.wordpress.org/reference/functions/add_rewrite_tag/
 */
function myplugin_rewrite_tag()
{
    add_rewrite_tag('%book-author%', '([^&]+)');
}

add_action('init', 'myplugin_rewrite_tag', 10, 0);


/**
 * Add rewrite rules
 *
 * @link https://developer.wordpress.org/reference/functions/add_rewrite_rule/
 */
function myplugin_rewrite_rule()
{
    add_rewrite_rule('^book/book-author/([^/]*)/?', 'index.php?post_type=book&book-author=$matches[1]', 'top');
}

add_action('init', 'myplugin_rewrite_rule', 10, 0);


/**
 * Flush rewrite rules on activation
 */
register_activation_hook( __FILE__, 'myplugin_activate' );
function myplugin_activate() {
    create_posttype_book();
    myplugin_rewrite_tag();
    myplugin_rewrite_rule();
    flush_rewrite_rules( false );
}

register_deactivation_hook( __FILE__, 'myplugin_deactivate' );
function myplugin_deactivate() {
    flush_rewrite_rules( false );
}

FAQs

Common questions about the WordPress Rewrite API and building custom permalink structures:

What is the difference between a Rewrite Tag and a Rewrite Rule?
A Rewrite Tag is a placeholder (like %postname%) that represents a dynamic part of the URL. A Rewrite Rule is the pattern that maps a clean permalink-style URL to the query variables WordPress needs to execute the database query. You need both - the tag defines the placeholder and the rule defines the connection between it and the query.
When should I use flush_rewrite_rules()?
Only use flush_rewrite_rules() inside register_activation_hook and register_deactivation_hook. Never call it on the init hook or on every page load, as it writes to the database on each execution and can cause significant performance issues. Alternatively, you can manually go to Settings > Permalinks in the WordPress dashboard and click "Save Changes."
Can I use the Rewrite API in a theme instead of a plugin?
Yes, you can use the Rewrite API in your theme's functions.php file. However, using a plugin is recommended because the rules will continue to work even if you switch themes. If the rules are defined in a theme and you switch to a different one, the rewrite rules will disappear and visitors will get 404 errors. Additionally, themes don't have register_activation_hook, so you'll need to flush rewrite rules manually through the admin interface whenever you make changes.
Why are my rewrite rules not working?
The most common cause is that you forgot to flush the rewrite rules. Go to the WordPress admin > Settings > Permalinks and click "Save Changes." Other possible causes include: your Rewrite Rule is registered after an existing rule that catches the same URL pattern (use the top parameter to give it priority), the Query Var is not registered via the query_vars filter, or there is an error in the regular expression (regex) of the rule.
What is the difference between Public and Private Query Vars?
Public Query Vars can be used directly in the URL (e.g., ?author_name=moshe). Private Query Vars can only be used within PHP code through WP_Query and are not accessible via the URL. For example, meta_key and meta_value are private variables - if you try to use them directly in a URL, WordPress will ignore them.
Is it important to sanitize values from Query Vars?
Absolutely. Values from Query Vars are user input and may contain malicious code. Always use WordPress sanitization functions such as sanitize_text_field(), absint(), or esc_attr() depending on the expected value type, before using the value in a query or displaying it on a page.

Thanks to WordPress Developer Resources for the Rewrite API documentation.

Join the Discussion
0 Comments  ]

Leave a Comment

To add code, use the buttons below. For instance, click the PHP button to insert PHP code within the shortcode. If you notice any typos, please let us know!

Savvy WordPress Development official logo