To add a custom rewrite rule in WordPress, call add_rewrite_rule() on the init hook, map your pretty URL to an index.php?... query, and pick whether your rule runs before or after the built-in rules:
add_action( 'init', 'te_add_rewrite_rules' );
function te_add_rewrite_rules() {
add_rewrite_rule(
'^thing/([^/]+)/?$',
'index.php?thing_name=$matches[1]',
'top'
);
}That single call is necessary but not sufficient. A rewrite rule on its own almost always "does nothing" until you also register the custom query variable so WordPress will populate it, read it on the front end, and flush the rules once so the new pattern is actually live. This article walks the whole loop: the regex, the query target, the priority, the query var (the part everyone forgets), reading the value, loading a template, and flushing without shooting yourself in the foot.
This is the foundation piece for everything else I have written about WordPress routing, so it is deliberately complete rather than a snippet dump.
The regex, the rewrite target, and the position
Per the add_rewrite_rule reference, the signature is three arguments:
add_rewrite_rule( string $regex, string|array $query, string $after = 'bottom' );The first argument is a regular expression matched against the request path. Take the example above apart:
^anchors the match to the start of the path (the part after your site root). WordPress strips the leading slash before matching, so you anchor onthing, not/thing.thing/is the literal URL segment you are claiming.([^/]+)is a capture group: one or more characters that are not a slash. That captured value is the dynamic part of the URL, the slug or ID you want to read later./?allows an optional trailing slash so both/thing/widgetand/thing/widget/match.$anchors the end so the pattern does not greedily swallow deeper paths.
The second argument is the internal query the matched URL rewrites to. WordPress never actually serves a file called index.php?thing_name=widget; that string is the canonical query it parses internally to figure out what to load. The $matches[1] token refers to the first capture group in your regex, so a request for /thing/widget is rewritten to index.php?thing_name=widget. If you had a second capture group you would reference it as $matches[2], and so on.
The third argument is the priority, named $after, and it accepts 'top' or 'bottom' (default 'bottom'). This decides whether your rule is checked before or after WordPress's built-in rules:
'top'puts your rule first, so it wins before core's page, post, and taxonomy rules get a chance to match. Use this when your pattern could otherwise be caught by a more general core rule (a single-segment/thing/can collide with the page or attachment rules).'bottom'appends your rule after the core set, so it only matches requests nothing else claimed. Safer when your pattern is broad and you do not want to shadow real pages.
When in doubt for a custom, clearly-namespaced front segment like thing/, 'top' is the usual choice. The official handbook example uses 'top' for exactly this reason.
Register the query var (the silent-failure trap)
Here is the single most common reason a rewrite rule appears to do nothing: WordPress will not populate a query variable it does not know about. Adding the rewrite rule rewrites the URL to index.php?thing_name=widget, but WP_Query ignores thing_name unless you have explicitly told WordPress it is a public query var. So get_query_var( 'thing_name' ) comes back empty, your template logic never fires, and the rule looks broken when it is actually working fine.
Register the var with the query_vars filter:
add_filter( 'query_vars', 'te_register_query_vars' );
function te_register_query_vars( $vars ) {
$vars[] = 'thing_name';
return $vars;
}The filter hands you the array of recognized public query vars; you push your custom name onto it and return the array. After this, a request that matched your rule actually carries thing_name into the main query.
I lost more time than I want to admit, early on, to exactly this: a perfectly good add_rewrite_rule() call and no query_vars filter to back it. The URL resolved, no error appeared, and the value was just never there.
Read the value and load a template
With the var registered, you can read it anywhere after the query is parsed using get_query_var():
$thing = get_query_var( 'thing_name' );The interesting question is usually not "what is the value" but "what should this URL render." For a custom front-end route you typically want to load your own template file rather than letting WordPress fall back to index.php. The cleanest hook for that is template_include, which lets you return the absolute path of the template to use:
add_filter( 'template_include', 'te_thing_template' );
function te_thing_template( $template ) {
$thing = get_query_var( 'thing_name' );
if ( ! empty( $thing ) ) {
$custom = plugin_dir_path( __FILE__ ) . 'templates/thing.php';
if ( file_exists( $custom ) ) {
return $custom;
}
}
return $template;
}Inside templates/thing.php you call get_query_var( 'thing_name' ) again, look up whatever that slug maps to, and render it. Because you returned a real template path, WordPress runs that file through the normal output path (header, your markup, footer), so get_header() and get_footer() work as expected.
If you only need to run code (an API proxy, a redirect, a download handler) rather than render a themed page, hook template_redirect instead, do your work, and exit. Use template_redirect for side effects and short-circuits; use template_include when the route should render a page.
One thing to watch: a bare custom URL like /thing/widget will, by default, return a 200 with whatever template you load, but WordPress may still consider it a 404 for conditional tags. If you care about is_404() and the status header, set the query up so it is not treated as a missing page (for simple routes, returning your own template from template_include and not relying on the main loop is usually enough; for richer cases you would register a real rewrite endpoint or a custom post type instead of a raw rule).
Flush the rules once, and never on every request
Rewrite rules are not evaluated from your PHP on every page load. WordPress compiles them into a big regex map and caches it in the rewrite_rules option in the database. Your freshly added rule does not exist in that cached map until the map is rebuilt, which is why a brand-new add_rewrite_rule() call seems to do nothing until you flush.
You flush exactly once, after adding or changing a rule. There are three honest ways to do it:
- Visit Settings, then Permalinks in wp-admin and click Save. Loading that screen rebuilds and re-saves the rules. This is the manual go-to.
- Run
wp rewrite flushwith WP-CLI. Same effect from the shell, handy on a server. - Call
flush_rewrite_rules()from code, but only inside an activation hook. For a normal plugin, hang it offregister_activation_hook()so it runs once when the plugin is activated, not on every page.
register_activation_hook( __FILE__, 'te_flush_rules_on_activate' );
function te_flush_rules_on_activate() {
te_add_rewrite_rules(); // make sure the rule exists this request
flush_rewrite_rules(); // then rebuild the cached map once
}The footgun: never call flush_rewrite_rules() on init (or on any per-request hook). It is an expensive operation that rewrites the rewrite_rules option in the database, and doing it on every single front-end and admin request is a real, measurable performance hit that also writes to the DB constantly. It is one of those snippets that gets copy-pasted into functions.php, "works" because the rule does show up, and quietly taxes the site forever after. Add the rule on init; flush it once, separately.
One wrinkle worth knowing: must-use plugins have no activation hook. A file in wp-content/mu-plugins/ loads automatically and never "activates," so register_activation_hook() will not fire there. For an mu-plugin, add the rule on init as usual and flush manually one time (Settings, then Permalinks, or wp rewrite flush) after you drop the file in. Do not reach for flush_rewrite_rules() on init to compensate; that is exactly the per-request flush you are trying to avoid.
The complete must-use plugin
Putting the whole loop into one file. I keep route code in an mu-plugin so it cannot vanish on a theme switch:
<?php
/**
* Plugin Name: TE Rewrite Rule
* Plugin URI: https://techearl.com/wordpress-add-rewrite-rule
* Description: A worked example of a custom WordPress rewrite rule: the rule, the query var, the template loader, and a single activation-time flush.
* Version: 1.0.0
* Author: Ishan Karunaratne
* Author URI: https://techearl.com
* License: GPL-2.0-or-later
* Text Domain: te-rewrite
*/
add_action( 'init', 'te_add_rewrite_rules' );
function te_add_rewrite_rules() {
add_rewrite_rule(
'^thing/([^/]+)/?$',
'index.php?thing_name=$matches[1]',
'top'
);
}
add_filter( 'query_vars', 'te_register_query_vars' );
function te_register_query_vars( $vars ) {
$vars[] = 'thing_name';
return $vars;
}
add_filter( 'template_include', 'te_thing_template' );
function te_thing_template( $template ) {
$thing = get_query_var( 'thing_name' );
if ( ! empty( $thing ) ) {
$custom = plugin_dir_path( __FILE__ ) . 'templates/thing.php';
if ( file_exists( $custom ) ) {
return $custom;
}
}
return $template;
}
// Regular plugin: flush once on activation. In an mu-plugin this hook never
// fires, so flush manually instead (Settings > Permalinks, or wp rewrite flush).
register_activation_hook( __FILE__, 'te_flush_rules_on_activate' );
function te_flush_rules_on_activate() {
te_add_rewrite_rules();
flush_rewrite_rules();
}As written, the register_activation_hook() line is a harmless no-op inside mu-plugins/; it only does anything when the file is a normal, activatable plugin. Either way you flush exactly once.
Verify it with wp rewrite list

The fastest way to confirm a rule is actually compiled into the cached map is WP-CLI:
wp rewrite list --format=table | grep thingIf your rule is live you will see the regex on the left and the index.php?thing_name=$matches[1] rewrite target on the right. No row means the map has not been flushed since you added the rule, so go flush it. You can also dump the lot with wp rewrite list and eyeball where your rule landed relative to the core rules, which is a quick sanity check that your 'top' versus 'bottom' choice did what you expected.
Then load /thing/widget in a browser. If you get a 404, the usual suspects, in order: the rules were never flushed, the query_vars filter is missing so the value never populates, or another rule matched first (revisit 'top' vs 'bottom').
See also
- Add a Custom REST API Endpoint in WordPress: the modern counterpart to a rewrite rule when you want JSON out rather than a rendered page, using
register_rest_route()instead of the Rewrite API - Clean Up wp_head in WordPress: trimming the default head output, the same kind of small, surgical hook work you reach for once you are comfortable adding actions and filters
- How to Optimize WooCommerce: why flushing rewrite rules on every request is exactly the sort of quiet, repeated database write that drags a store down
Sources
Authoritative references this article was fact-checked against.
- add_rewrite_rule(): WordPress Developer Referencedeveloper.wordpress.org
- query_vars filter: WordPress Developer Referencedeveloper.wordpress.org
- Rewrite API: WordPress Plugin Handbookdeveloper.wordpress.org





