How to Add a Custom URL to WordPress with Rewrite Rules

Share
In this tutorial, I'll explain how to use WordPress (version 6.4.3) functions add_rewrite_rule, add_query_var, and get_query_var to add a custom URL (e.g. /custom-widget/ or /gallery/123/sort-by-resolution) to your WordPress theme (functions.php) or plugin.

Use cases that will be covered in this tutorial:

  • Creating a custom /my-page URL.
  • Creating a custom /my-directory/ URL, because it doesn't work the way you expect.
  • Creating a custom /my-directory/param1/param2/param3 URL.
  • Making a URL show a post that you already created.
  • Making a URL show custom content or PHP code that you'll write in a function yourself, e.g. XML or JSON content.

Why Not Just Use PHP?

The first thing you should consider is whether or not you need to use WordPress for this.

Depending on your web server setup, you may be able to just create a new folder, put a .htaccess file in it, and add an .index.php to render. For example, you could do this if you want to have a /current-time/ URL that just shows you what the current time is.

The only reason to do it inside WordPress is so you can make use of WordPress utilities and configuration, such as accessing the database to get post data, and checking whether or not the user is logged in WordPress or has permission to access edit posts, and making use of theme templates and WordPress blocks.

Project Setup

Let's keep things organized: if you're adding this to your theme, go to your theme folder, create a my-custom-url.php file, and add require "my-custom-url.php to your functions.php file. If you're doing this for your plugin, do the same thing but with your index.php file.

<?php
// Add this to your functions.php or index.php file
// after you create a my-custom-url.php
require "my-custom-url.php"

All the code we'll see in this tutorial can placed in this new PHP file. We don't touch any code of any other files.

How WordPress Handles URLs

Let's start by understanding how WordPress handles URLs, and how the whole thing figure out which posts to show. For that, we need to go a step further back and understand how WordPress initializes itself.

WordPress Init Hook

1: when WordPress starts up, it will check what's the active theme and what are the active plugins.

2: WordPress will then use the PHP statements include or require to include the PHP files of that theme and of those plugins. WordPress only includes one file, such as the functions.php of a theme or ìndex.php of a plugin.

3: the code in functions.php or index.php is executed immediately. If we have an include or require statement in these files, it will include the code of another file, which will ALSO be executed immediately.

4: WordPress finishes initialized itself. This is very important: when the code in functions.php is run, WordPress isn't done initializing itself yet, which means that if we want to do something with WordPress after it's initialized, we can't do it in the code that is executed when functions.php is included, as this code would run before WordPress is ready.

5: WordPress executes all functions registered in the init hook.

From this algorithm, we can already have an idea of what we have to do. If we want to do something with WordPress AFTER it's initialized, we have to register a function in the init hook before it's initialized, when the code of function.php and the files it includes is executed.

Essentially, we will have something like this:

<?php
function my_plugin__init() {
    /* do something with WordPress after it's initialized */
}
add_action('init', 'my_plugin__init');

The code above will be executed in the instant it's executed. However, the code inside function my_plugin__init is only executed when the function is called. Which means whatever we write inside the function won't get executed just by including the file.

The only thing that will be executed is the WordPress command add_action, which registers to a hook (init), a callback function (my_plugin__init). Observe that both of the arguments of this command are strings surrounded by single quotes ('). You must pass the name of the function as a string to this command, which means the following won't work:

add_action(init, my_plugin__init); // This won't work.

On top of that, in PHP all functions of all files are defined in the global namespace by default. This means if you name your function just init instead of my_plugin__init, and there's another function already called init, you'll have problems. Keep your function names unique by using prefixes. There are other methods, such as anonymous functions, but personally I prefer prefixes.

Rewrite Rules and Query Vars

WordPress is design to work with URLs such as /index.php?p=123, where index.php is the index.php of WordPress where WordPress is installed (where you unzipped WordPress), and p=123 are query parameters of the URL, which in WordPress means to display the post whose ID is 123.

In order to make the URLs become pretty permalinks, with friendly slugs, like /articles/123/human-readable-slug, it's necessary a more complex setup. That's because WordPress works by executing the file index.php, and this pretty URL doesn't have index.php written anywhere in it, so what needs to happen is that for some other program to take this URL and pass it to PHP. This other program is the web server, such as Apache, which is configured with the .htaccess file.

When properly configured, Apache will run index.php and pass to it the URL that is being currently accessed. The whole URL, including the domain name (e.g. https://www.virtualcuriosities.com/).

This means WordPress has the code to handle both "ugly" URLs like /index.php?p=123 and the code to handle pretty URLs like /articles/123/. It does this by converting the former into the latter, and then only really handling the latter.

  1. Take a pretty URL and convert it into a ugly URL.
  2. Take the ugly URL parameters (e.g. p=123), and figure out what to display.

In the first page we have what are called "rewrite rules." A rewrite rule is literally a rule that tells WordPress to rewrite /articles/123/ into index.php?p=123.

The second stage is where we have what are called "query vars" (query variables). This is a very confusing name, but it essentially means we have a query variable p whose value is 123.

If you know PHP or other web frameworks, you may assume that these are actual URL query parameters sent to the web servers and that can be accessed with $_GET. They are not. WordPress parses the rewritten URLs internally. This means you can't, for example, create a URL rule that rewrites to favicon.ico instead of index.php, because Apache won't be the one handling the rewritten URL, nor is it going to be PHP handling it, it's WordPress internal code handling this. You can still create a rewrite rule that redirects or serves a favicon.ico file from PHP, but it's generally better to do this in .htaccess to skip PHP entirely.

Note: most WordPress websites use pretty permalinks for SEO and basic usability, but it's generally possible to access any post by its ID by typing ?p=123 after the site's domain name. We can use this to test if our query vars are working.

Adding a Simple Rewrite Rule to Show an Existing Post

Let's start with a simple use case. We have a post in WordPress already, and we want to give it a custom URL instead of using the permalink system for some reason. Although this sounds simple enough, there is a few problems we may encounter.

The first thing we need is some code. Let's start with this example:

</php
function my_plugin__init() {
    $cool_post_regex = '^my-cool-post$';
    $cool_post_id = 123;
    add_rewrite_rule(cool_post_regex, 'index.php?p='.$cool_post_id, 'top');
}
add_action('init', 'my_plugin__init');

Change $cool_post_id to the ID of the post that you want to display. Having done that, you should be able to view your post by accessing example.com/my-cool-post (replace example.com with the website URL), after you have flushed the rewrite rules.

There are a few things we'll need to learn before we continue, such as what flushing the rewrite rules mean, what query vars we can use, what is regex, and some problems you may have dealing with how WordPress handles regex.

Flushing the Rewrite Rules

WordPress keeps a list of rewrite rules stored in the database that it uses to check which rewrite to use. This sounds rather odd since we're calling add_rewrite_rule up there. Essentially, calling add_rewrite_rule HAS NO EFFECT if the rules aren't flushed. So basically what happens is this:

  1. WordPress calls init callbacks.
  2. The init callbacks register their rules.
  3. WordPress completely ignores it, and gets the actual rules from the database.

You may be wondering why is this system designed like this. I am as well. From experience, this should mean that flushing the rules is an expensive operation, considering that this has to be done every time you access a page from WordPress, but I'm not quite sure why. Perhaps some expensive sorting or parsing needs to be done? I glanced at the source code and I noticed flushing can overwrite .htaccess, so maybe that's why.

In any case, in order for WordPress to be aware of our new rules, we need to flush them. We can do this three ways:

  1. The sane way, which is to use wp rewrite flush from the command line. You need to have WP-CLI installed for this, which you can get from the official WordPress website (it's not a plugin, it's just core tools).
  2. The debug way, which is to call flush_rewrite_rules() after we added the rules, which is going to get called every time until we remove it.
  3. The accidental way, which is to activate or deactivate a plugin.

I've seen some tutorials just tell you to use flush_rewrite_rules() and forget to mention you're supposed to remove this code later, as it's apparently expensive to run. For now, we can do this:

// add this after add_rewrite_rule(...);

// TODO: remove this later to make site faster
flush_rewrite_rules();

Don't forget to remove it later.

Note that WordPress doesn't actually flush the rules when you call flush_rewrite_rules from init. It will just remember the function has been called and flush it after all plugins have been initialized, so you don't have to worry about when to call it.

If you are one of the cautious individuals who doesn't just go around changing code in prod—and you have a development environment running locally on your computer and then you have to upload the changes to an online server, the production server—then you should be aware that, because the rules are saved to the database, and your development database is (hopefully) not your production database, flushing the rules locally has no effect on the rules in production, and you'll need to flush them in production too.

If you can't use the command line or SSH for some reason, or you don't have WP-CLI, it's still possible to flush the rewrite rules by deactivating or activating any plugins. Whenever you change your plugins or theme, WordPress flushes the rewrite rules. Note that some plugins may do things like creating or deleting stuff when they're activate or deactivated, so if you do it this way do it with a plugin that you know has no harmful side-effects.

Using Other Query Vars

Before we learn how to create our own custom query vars, it's worth noting that there are other query vars that come with WordPress by default, and that you can display (almost) any page that WordPress displays by default using them.

The WordPress codex has a list of them1, although it doesn't say what each of them does. For reference, a copy of the public query vars:

attachment
attachment_id
author
author_name
cat
calendar
category_name
comments_popup
cpage
day
error
exact
feed
hour
m
minute
monthnum
more
name
order
orderby
p
page_id
page
paged
pagename
pb
post_type
posts
preview
robots
s
search
second
sentence
static
subpost
subpost_id
taxonomy
tag
tag_id
tb
term
w
withcomments
withoutcomments
year

These "public" query vars can be set by accessing a URL that has them in the query parameters of the URL, such as ?p=123. Besides these, there are also "private" query vars that can only be set using a rewrite rule, in this case, the rewrite rules that WordPress uses by default.

Note that sometimes it's not possible to render a page that WordPress already has just by using a rewrite rule with one of these. That's because they only work if your custom rewrite rule has the same parameters as WordPress URL rules, since they didn't add more parameters to cover every case that doesn't come working by default on WordPress.

For example, one problem I ran into is that, as you can see above, we have tag and tag_id, but only term and no term_id. This means it's possible to show a tag page by its slug OR id, but if you create a custom taxonomy, you can only show a term using its slug, not its id, at least if you limit yourself to using what WordPress has by default.

The way WordPress works after turning pretty URLs into ugly URLs, is that it takes these query vars and tries to build the global wp_query with it. Depending on the query vars, it will figure out if the URL is supposed to display a post content or the list of posts in a category, and this defines what is the type of the main query.

If the type is undefined, then, by default, it will show the homepage, the most recent posts.

It is possible to make WordPress display any page with any parameter if we customize wp_query with a custom parameter before this later stage occurs, which can be done with the parse_query hook, and some other things we'll learn below.

Regex and Intricacies of Rewrite Rules in WordPress

Regex, short for REGular EXpressions, is de facto the standard way to match strings in pretty much every programming language. You should learn some basic regex sometime, it might help you some day.

In regex code, ^ matches the start of a string, and $ matches the end of a string. This is necessary to avoid my-cool-post URLs such as my-cool-post-2 and rate-my-cool-post, which include my-cool-post inside of them. When we surround a regex pattern with ^ and $, we're telling the regex engine that the pattern must span the whole text, from start to finish.

In regex, some characters have special meanings. Notably:

  • . means "any character."
  • \ escapes a special character, e.g. cool-sitemap\.xml escapes the .. If the pattern was cool-sitemap.xml, it would match cool-sitemapXxml or anything else before xml.
  • ? means "the previous character zero or one time."
  • + means "the previous character one or more times."
  • * means "the previous character zero or more times."
  • {} matches the previous character a specific number of times, e.g. z{3} matches zzz.
  • {x,y} matches the previous character from X to Y times. Either can be omitted, e.g. z{2,6} matches zz to zzzzzz.
  • () surrounds a group, which will be treated as if it were a single character by the codes above, e.g. (no ?)*no! matches "no no nono no nonono!".
  • | means "or," e.g. `I say (no|yes) matches both "I say no" and "I say yes."
  • [] matches any character between brackets, e.g. [nN]o matches "no" and "No" but not "nO," assuming it's case-sensitive matching.
  • [^] matches any character NOT between brackets, e.g. posts/[^/]+/gallery will match any character except the forward slash (/), so posts/cool-post/gallery is matched, but posts/cool/post/gallery is not.

There are more, and how they work exactly may depend on regex engine used. One very important one that is necessary is edge cases is the non-greedy .+? and .*?, which avoids matching more than necessary.

The backward slash can be used in combination with some codes to match special things. Notably \d matches a numeric character. It's possible to use dashes in [] to match ranges, e.g. [0-9]. Sometimes \d is implemented such way that it matches characters that aren't just 0123456789, so you have to use the brackets.

No First Forward Slash

In some frameworks, URLs start with a /, so you would need to write ^/my-cool-post$ instead of ^my-cool-post$. However, in WordPress, it seems you don't do this.

Trailing Slash at the End of URLs

For some reason, add_rewrite_rule doesn't work as you would expect when you want a trailing slash, e.g. /my-cool-post/ instead of /my-cool-post. To add a trailing slash in WordPress, you need to use the regex code /? instead of /. Observe:

add_rewrite_rule('^no-slash$'    , 'index.php?p=123', 'top');
add_rewrite_rule('^with-slash/?$', 'index.php?p=123', 'top');
add_rewrite_rule('^not-working/$ , 'index.php?p=123', 'top');

This is kind of awkward because /? should make the trailing slash optional according to what it means in regex, however what actually happens is that WordPress will automatically redirect with-slash to with-slash/ if you use /?.

I assume that WordPress internally checks if a rewrite rule ends with /? to do this automatic redirect, and it needs the ? so that it works some rule was matched as it has no final fallback that just checks all the possible rules that could have been.

I don't know why / without /? doesn't work, however. I assume this may be a bug.

Note: there is no difference in practice between /thing and /thing/. It's just that, by convention, you use a trailing slash if the URL is a kind of page that lists other pages, i.e. a directory-like page.

Another problem is that WordPress will redirect your URLs to the trailing slash counterpart automatically, which is a problem if you want something that ends in .xml instead of .xml/. See below for how to fix.

String Matching is Limited to Path

It's worth noting that technically a URL can be something like https://www.example.com/url/path?q=query, but in this case the regex patterns only match url/path. The domain isn't match, and, most importantly, the query part ?q=query isn't match either. This means ^ and $ refer to the start and end of just the url/path, so even if you use $ the matched URL may still have random $_GET parameters added to it.

Using Variables from the URL

Let's say that we want /view-post-123-here to display the post with an ID 123. To do this, we need to use a capture group in the regex pattern of our add_rewrite_rule and a $matches[1] code inside the string of the second argument. For example:

add_rewrite_rule('^view-post-([^/]+)-here$', 'index.php?p=$matches[1]', 'top');

There are a couple things worth noting.

First, the ([^/]+) in our regex is a group, as we can see by the parentheses. Groups can be matching or non-matching: this one is a matching group. This means that the regex engine will store the contents of this group in a matches array somewhere.

In PHP, arrays start at 0, so you are probably confused why $matches[1] is used. Shouldn't it be $matches[0]? Normally, when you use regex directly, the zeroth match is the whole matched string. For example, if we matched view-post-123-here, then $matches[0] would be view-post-123-here, and $matches[1] would be 123.

Make sure you surround your strings by single quotes ('). In PHP, a string surrounded by double quotes (") is automatically formatted, which, in this case, means PHP will try to find a $matches variable you haven't defined in the current scope to substitute the $matches in the string with the variable's current value. That's not what we're supposed to do. We need to literally give WordPress a string that has $matches[1] written in it, so that WordPress will later replace $matches[1] with the corresponding value when (and if) it matches the rule.

Using Multiple Variables

You can have multiple capturing groups in the regex and multiple parameters in the index.php side. Note that, like a normal URL, you need to use the ampersand (&) between query variables.

add_rewrite_rule(
    '^view-post-([^/]+)-here/page-([^/]+)$',
    'index.php?p=$matches[1]&paged=$matches[2]',
    'top'
);

Naturally, the second matching group is replaced using $matches[2] instead of $matches[1].

Rendering Custom PHP Code at a Custom URL

To output from custom PHP code at a custom URL, we need to do two things besides writing a rewrite rule:

  1. We need to use $wp->add_query_var to create a custom query var to identify when our custom view function should be run.
  2. We need to add a callback to the template_redirect hook so we can check if our custom query var is set, which means that the custom view function should be run.

Note: some tutorials use add_rewrite_tag, but that's not necessary unless we're using add_permastruct, because all add_rewrite_tag does is call $wp->add_query_var when you leave the last argument empty and register the tag.

As you can imagine, this is far more complex than just showing a URL WordPress already supports at a different URL.

Let's see an example in code:

<?php
function my_plugin__init() {
    // add custom query var
    global $wp;
    $wp->add_query_var('my_custom_query_var');
    
    // map URL to query var
    add_rewrite_rule("^my/(.+)$", 'index.php?my_custom_query_var=$matches[1]', 'top');
}

function my_plugin__template_redirect() {
    global $wp_query;

    // this function is run on ANY URL
    // even the ones that aren't ours to handle,
    // so we need to distinguish when the URL is ours or not.
    
    // get our custom query var
    // which is only set in our custom URLs
    // the second argument is the default value,
    // use false so we can handle empty strings too.
    $my_custom_var = get_query_var('my_custom_query_var', false);
    if(false === $my_custom_var) {
        // the custom variable is unset
        // this is not our URL so we have no business here.
        // let someone else handle this.
        return;
    }
    
    // the current URL is ours
    // add custom code here
    // example:
    
    if($my_custom_var === '') {
        // this is the my/ URL
        // show list of all custom things
        my_plugin__render_list_page();
        exit;
    }

    // this is a my/custom-var URL.
    // get the thing and render it
    $thing = my_plugin__get_the_thing($my_custom_var);
    if(false === $thing) {
         // thing not found
        $wp_query->set_404();
        status_header(404);
        return;
    }

    my_plugin__render_thing($thing);
    exit;
}

// Register hooks
add_action('init'             , 'my_plugin__init'             );
add_action('template_redirect', 'my_plugin__template_redirect');

One thing to note is that when you use template_redirect you need to use the command exit; instead of return; when you handle the URL. That's because if you use return;, WordPress will proceed as normal, and you'll get shown your homepage.

Why does it show the homepage instead of 404? Because WordPress only shows 404 if none of the rewrite rules match. Since our rewrite rule matched, it won't show 404. The reason for this design, presumably, is that you could have a rewrite rule that was supposed to show the homepage with some cool effect in it, consequently we end up having to do two things:

First, if the URL is ours to handle, and we handle it successfully, we use exit; to stop WordPress from trying to handle it after we're already done.

Second, if we haven't handled it successfully, such as a 404 error, and we want to let WordPress handle it, then we need to force WordPress to show the 404 page instead of the default homepage. We can do this with $wp_query->set_404(). If we called exit; here, the web server would return a 404 HTTP status code, but we wouldn't see our custom WordPress 404 page (with a search box or something like that). We'd probably just see a blank page. Meanwhile, if we called only status_header(404), we would still see the homepage, except with a 404 HTTP code that isn't visible on the page.

The code of the rendering functions work just like any PHP file. For example:

function my_plugin__render_thing($thing) {
    ?><!DOCTYPE html>
<meta charset=utf-8>
<title><?= esc_html($thing->name) ?></title>
<p>Welcome to the page of <?= esc_html($thing->name) ?>.</p>
<?php
}

If you want to keep things organized, you can place the PHP and HTML in a separate file and just include it:

function my_plugin__render_thing($thing) {
    require "thing-template.php";
}

Multiple URLs with View Names

If you want to register multiple URLs, you can reuse the same query var instead of creating a new one for each URL. For example:

<?php
function my_plugin__init() {
    global $wp;
    $wp->add_query_var('my_view_name');
    
    add_rewrite_rule("^my/top-posts$", 'index.php?my_view_name=top-posts', 'top');
    add_rewrite_rule("^my/new-posts$", 'index.php?my_view_name=new-posts', 'top');
}

function my_plugin__template_redirect() {
    $my_custom_var = get_query_var('my_view_name');
    if(empty($my_custom_var)) {
        return;
    }

    if($my_custom_var === 'top-posts') {
        // render top posts
        exit;
    } else if($my_custom_var === 'new-posts') {
        // render new posts
        exit;
    } else {
         // this should never happen, but just in case.
        $wp_query->set_404();
        status_header(404);
        return;
    }
}

// Register hooks
add_action('init'             , 'my_plugin__init'             );
add_action('template_redirect', 'my_plugin__template_redirect');

Regexes for Multiple Query Vars

For reference, some regexes that you may need if you want to create complex custom URLs:

Don't Match Slashes (Only Directory Names)

add_rewrite_rule(
    '^my/([^/]+)/top-posts$',
    'index.php?my_directory=$matches[1]&my_view_name=top-posts',
    'top'
);

Matches: my/cool-posts/top-posts.

Doesn't match: my/very/cool/posts/top-posts.

Match Extensions Correctly

add_rewrite_rule(
    '^my/page\.json$',
    'index.php?my_view_name=json-page',
    'top'
);

add_rewrite_rule(
    '^my/page\.(xml|rss)$',
    'index.php?my_view_name=xml-or-rss-page',
    'top'
);

add_rewrite_rule(
    '^my/page\.(jpe?g)$',
    'index.php?my_view_name=jpg-page',
    'top'
);

Use \. to match the dot (.) character correctly.

You can use groups to match alternative spellings. And you could try redirecting to the canonical URL in template_redirect.

Match ID or Slugs in Same Location

add_rewrite_rule(
    '^my/([0-9]+)/view$',
    'index.php?my_id=$matches[1]',
    'top'
);

add_rewrite_rule(
    '^my/([^0-9/][^/]*)/view$',
    'index.php?my_slug=$matches[1]',
    'top'
);

Matches: my/123/view and my/cool-post/view, setting a different query var when you use an ID instead of a slug.

If you use just [^/]+ instead of [^0-9/][^/]* you'll run into a problem where 123 matches the pattern [^/]+. To avoid this, the solution above is to avoid matching if the first character is a digit: we do this with [^0-9], which matches anything but a digit. This would match a forward slash as well, so we need [^0-9/]. Since the first character is already matched, we need to change + (one or more characters) to * (zero or more characters) in order to match single-character slugs.

Note that this commonly used solution has one caveat: you can't have a slug starting with a number. If you have ever used an application or a website that doesn't let you start your username or the name of something with a number, the reason is an algorithm like this.

Trailing Slash Redirects

By default, WordPress redirects URLs that don't end in a trailing slash to URLs that end in a trailing slash. This behavior comes from the function redirect_canonical, which is immense. To make matters more confusing, if you see some of the rewrites in WP's own code, such as wp-sitemap.xml, you'll see they just the same regex patterns we used, and yet they work differently. What gives? Well, redirect_canonical is a huge function that includes exceptions for many of WordPress' own cases, that's why our code doesn't work even though we're using the same code as them.

There are two ways to stop WordPress from automatically adding trailing slashes to our URLs:

  1. With the hook redirect_canonical, by returning false.
  2. Because redirect_canonical is actually a callback added to template_redirect, it's possible to add our callback to be executed before it by using a lower priority. The default priority (which it used) is 10, so if we use add_action with 9 priority, redirect_canonical will never get called because we exit; before that happens.

For the first method, we would use this code:

function my_plugin__redirect_canonical($redirect_url, $requested_url) {
    $my_custom_var = get_query_var('my_custom_var', false);
    if(false === $my_custom_var) {
        // not our URL, so we return the same value we got.
        return $redirect_url;
    }
    
    // our url

    // disable automatic trailing slash redirects on our URLs.
    return false;
}
add_filter('redirect_canonical', 'my_plugin__redirect_canonical', 10, 2);

You can also return a different URL if you want to handle canonical redirects yourself.

This add_filter function does the same thing as add_action, it just has a different name.

The 10, 2 arguments are priority and the number of arguments passed to the callback, respectively. In this case, 2 arguments are passed: $redirect_url, and $requested_url.

For the second method, all we need to do is set a priority 9 or lower when we add the template_redirect callback:

add_action('template_redirect', 'my_plugin__template_redirect', 9);

Observations

For the sake of reference, this is how you add a custom URL in Python's Flask:

from flask import Flask

app = Flask(__name__)

@app.route("/")
def hello_world():
    return "<p>Hello, World!</p>"

Are you sure you want to use WordPress for whatever you're doing? I mean I'm not telling you to use Flask, but Django is pretty good as well.

References

  1. https://codex.wordpress.org/WordPress_Query_Vars (accessed 2024-06-07) ↩︎

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *