The way WordPress decides which template to use for a URL is a bit convoluted, so I've explained it in a separate article. See [How WordPress Tells which Template to Load for a URL]. This article will focus on how to make use of this information in practice.
Registering the URL Pattern
Our first step is to register what URL or URL pattern we want to handle. We do this by calling add_rewrite_rule in the init hook. This function takes a regex argument that describes our capture groups, and a second string that describes how to convert those captures into an URL with query parameters.
Those query parameters will be converted into "query vars" by WordPress if the query var is registered. We will need to register the query vars we create so they aren't discarded.
function my_plugin__init() {
global $wp;
$wp->add_query_var('foo');
$wp->add_query_var('bar');
add_rewrite_rule('^foo/?$', 'index.php?foo=1', 'top');
add_rewrite_rule('^foo/([^/]+)/?$', 'index.php?foo=2&bar=$matches[1]', 'top');
}
add_action('init', 'my_plugin__init');
Above, we register two query vars, foo and bar, and create two URLs ("routes" or "endpoints" if you prefer) associated with them.
The first pattern is for /foo or /foo/. Note that instead of the first forward slash we have ^, which in Regex means "the start of the string." On the other end we have "$" that means "the end of the string." We use both of these so the pattern described in the Regex needs to match the string from the start to the end, otherwise the URL /bar/foo/fish would match this rule because it has foo/ in the middle of it.
This first pattern just sets the foo query parameter to 1. Since foo is a registered query var, WordPress will take the URL query parameter and turn it into a WordPress query var.
If you have pretty URLs on your WordPress site, like /posts/123/, that's actually a rewrite rule that converts it to /?p=123. If you try to access /?p=123 on WordPress, it will try to load the post with the ID 123. Think of rewrite rules as the alternative way for passing URL query parameters, that is ALSO the default way because it looks prettier than having a URL full of ?, =, and & to set the query parameters.
The second pattern (^foo/([^/]+)/?$) has a capture group: ([^/]+). This regex means "any text character that isn't a forward slash (/) one or more times." The pattern will match foo/bar and foo/fish but not foo/bar/fish because that contains a forward slash after foo/. To pass this captured group as a URL query parameter, we use $matches[1] in the other string.
Observe that captured groups are indexed from 1, so the first group is $matches[1], not $matches[0]. I assume this happens because in Regex the zeroth match is the whole matched string. WordPress could have processed this in a different way, but they didn't, so it starts from 1.
Flushing Rewrite Rules
The rewrite rules are cached as an optimization method. The function flush_rewrite_rules needs to be called at least once after you change them in order for them to have effect.
If you are writing a plugin, the correct way to do this is by using the activation hook to call flush_rewrite_rules ONLY ONCE after your plugin is installed and activated. Calling it multiple times (e.g. every time on init) is a bad idea because it makes the whole caching system useless and gets rid of the optimization.
Another good method if you are the web master is to use the WP CLI (the terminal interface) to flush it with the terminal command wp rewrite flush.
If you're hacking your way through WordPress, you can simply add the code once in init and get rid of it later.
Finally, you can also manually flush the rewrite rules by going to Settings -> Permalinks and pressing the Save button even without changing anything.
Rendering the Template
The last step is to tell WordPress to render our custom .html block template using the same method it uses to render index.html, single.html, tag.html, category.html, and its other standard templates.
The main template loading code is found in template-loader.php. There are two methods we can use to customize it: template_redirect and template_include.
Template Redirect Method
Warning: although this is a popular method, WordPress' documentation warns against it, recommending to use the template_include method instead. See below for caveats.
The first thing template-loader.php does is calling the template_redirect hook, so in this method what we need to do is essentially copy and paste the important parts of template-loader.php in our custom handler for template_redirect, then call exit so the default template-loader.php program never gets executed. It should look like this:
function my_plugin__template_redirect() {
// gets "1" when accessing /?foo=1
$foo = get_query_var('foo', false);
if(empty($foo)) {
return;
}
// sets up templates/foo.html
$template = get_query_template('foo');
if($template) {
include $template;
} else {
// Aborts the program and
// responds with a 500 HTTP status.
wp_die("Template \"foo\" is missing.");
}
exit;
}
add_action('template_redirect', 'my_plugin__template_redirect');
As you can see, we check if foo is a query variable to figure out whether we are in a /foo/ URL or not. If we aren't in a /foo/ URL, then we have nothing to do here, so we just return.
Otherwise, we get the block template and include it, then exit. If the template isn't found, we need to handle that somehow. I'm not really sure what could we do about that except respond with 500 INTERNAL SERVER ERROR.
Template Include Method
The template-loader.php file uses the template_include filter to filter which template to include right before including it. This happens after it already decided which template to use according to WordPress' algorithms. This means we can use template_include to override the template. To use it, we would do this instead:
function my_plugin__template_include($template) {
// gets "1" when accessing /?foo=1
$foo = get_query_var('foo', false);
if(empty($foo)) {
return $template;
}
$template = get_query_template('foo');
if(empty($template)) {
// Aborts the program and
// responds with a 500 HTTP status.
wp_die("Template is missing.");
// If we don't use wp_die
// 200 OK is sent with a blank HTTP body.
}
return $template;
}
add_action('template_include', 'my_plugin__template_include');
As you can see the filter is not very useful if we need to load an entirely custom block template that we NEED to load or there will be an error.
It's more useful, for example, if we wanted to use a custom block template based on a post's metadata or some variable. In such case, if we can't find the custom template, we can simply return the $template we got as argument of the filter as fallback, e.g. if single-custom.html isn't found, just return single.html.
Issue: Redirects Happening Before Template is Rendered
If your custom template needs to use a query var that is already used by default by WordPress, you can run into some trouble because WordPress' code will run before yours.
For example, if you want to use the p query var in a custom URL, you'll be surprised to find that before your template renders, WordPress aborts the program with a redirect to the canonical URL of the post, and since the canonical URL doesn't match the regex pattern of your rewrite rules, it never gets run.
This happens because WordPress attaches by default the redirect_canonical handler to template_redirect with the default priority of 10. To override this behavior, you have to use a lower ordinal priority than redirect_canonical has in a template_redirect handler. Since its priority is 10, we can just use 9 to get called first.
add_action('template_redirect', 'my_plugin__template_redirect', 9);
If you need your template to be returned in template_include for some reason, it may still be possible to do that if you use an additional template_redirect handler to conditionally remove the redirect_canonical handler from the template_redirect hook. This sounds rather complicated, and I'm not sure what is the benefit, but it should be possible.