To turn a query-string URL like /?destination=paris&type=hotel into a clean path like /hotels/paris/, you register one rewrite rule whose regex captures multiple path segments and maps each one to a query variable. The whole route is three moving parts: an add_rewrite_rule() that does the capture, a query_vars filter that whitelists the variables you invented, and get_query_var() to read them back wherever you need them.
Here is the multi-segment rule. The first segment is a fixed word (hotels); the next two are captured and fed into two custom query vars:
add_action( 'init', 'te_register_listing_route' );
function te_register_listing_route() {
add_rewrite_rule(
'^([^/]+)/([^/]+)/?$',
'index.php?listing_type=$matches[1]&destination=$matches[2]',
'top'
);
}That maps /hotels/paris/ to index.php?listing_type=hotels&destination=paris internally, while the address bar keeps showing the clean path. The rest of this article is the two pieces that rule needs to actually work, plus the collision and flush gotchas that trip everyone up the first time.
Why this is not the Permalinks screen
Settings, Permalinks controls the URL shape of posts, pages, categories, and tags. You pick /%postname%/ or a dated structure, and WordPress generates the rewrite rules for its own content types from that setting. You do not write regex there, and you cannot invent new path segments that mean something to your own code.
This technique is for your own arbitrary routing: a search results page, a faceted listing, a microsite section, anything where the path segments are parameters you choose rather than a post slug WordPress already knows about. The Permalinks screen never produces a route like /hotels/paris/ that hands you two named values. add_rewrite_rule() does, and the query variables are how those captured segments travel from the URL into your PHP.
How the regex maps to query vars
The first argument to add_rewrite_rule() is a regular expression matched against the request path (everything after the domain, with the leading slash already stripped). Each parenthesised capture group becomes a numbered back-reference you can reuse in the rewrite target.
add_rewrite_rule(
'^hotels/([^/]+)/?$',
'index.php?listing_type=hotels&destination=$matches[1]',
'top'
);Read it left to right:
^hotels/anchors the rule to paths that begin with the literal wordhotels. This is the static leading segment, and it matters (more on that below).([^/]+)captures one path segment: one or more characters that are not a slash. That capture is$matches[1]./?$allows an optional trailing slash, then ends the match.
The second argument is the internal rewrite target. It always starts with index.php (WordPress routes everything through index.php), followed by a query string built from your custom variables. The capture groups are referenced as positional back-references in order: the first group is $matches[1], the second is $matches[2], and so on. To capture two segments and split them into two variables, add a second group:
add_rewrite_rule(
'^hotels/([^/]+)/([^/]+)/?$',
'index.php?listing_type=hotels&destination=$matches[1]&page_no=$matches[2]',
'top'
);Now /hotels/paris/2/ resolves to destination=paris and page_no=2. The third argument, top, inserts your rule ahead of WordPress's built-in rules so it gets the first look at the request. Use top for custom routes; bottom puts the rule after core's rules, which is rarely what you want for a route with its own fixed prefix.
Register the variables, then read them
WordPress will not pass through query variables it does not recognise. Custom names like listing_type and destination are not in its public whitelist, so the rewrite target above would set them and then WordPress would quietly drop them before your code ever sees them. You add them to the whitelist with the query_vars filter:
add_filter( 'query_vars', 'te_register_listing_vars' );
function te_register_listing_vars( $vars ) {
$vars[] = 'listing_type';
$vars[] = 'destination';
$vars[] = 'page_no';
return $vars;
}Every name you introduced in the rewrite target needs a line here. Miss one and that single value comes back empty with no error to explain it.
With the variables whitelisted, read them anywhere after the query is parsed (a template, a shortcode, a pre_get_posts callback) with get_query_var():
$listing_type = get_query_var( 'listing_type' );
$destination = get_query_var( 'destination' );
$page_no = (int) get_query_var( 'page_no', 1 );The second argument is a default returned when the variable is absent, which is handy for the page number. Note that get_query_var() returns whatever was in the URL: raw, unsanitised user input. Treat it like any other request data and sanitise before you use it (cast numbers with (int), run text through sanitize_text_field(), never interpolate it straight into SQL).
Drive a query or a template from the variables
Once you can read the variables you have two common jobs: bend the main query, or render your own template.
To filter the main loop, hook pre_get_posts and adjust the query before it runs. This keeps pagination, the main $wp_query, and the theme's archive template all working:
add_action( 'pre_get_posts', 'te_filter_listing_query' );
function te_filter_listing_query( $query ) {
if ( is_admin() || ! $query->is_main_query() ) {
return;
}
$destination = get_query_var( 'destination' );
if ( ! $destination ) {
return;
}
$query->set( 'post_type', 'listing' );
$query->set( 'meta_key', 'destination_city' );
$query->set( 'meta_value', sanitize_text_field( $destination ) );
}The is_admin() and is_main_query() guards are not optional: without them you will also rewrite admin-side and secondary queries and get confusing results. The early return on an empty destination means the callback does nothing on every other request.
If the route is its own thing rather than a variant of an existing archive, run a dedicated WP_Query in a custom template instead:
$destination = sanitize_text_field( get_query_var( 'destination' ) );
$listings = new WP_Query( array(
'post_type' => 'listing',
'meta_key' => 'destination_city',
'meta_value' => $destination,
) );
if ( $listings->have_posts() ) {
while ( $listings->have_posts() ) {
$listings->the_post();
// render each listing
}
wp_reset_postdata();
}The wp_reset_postdata() at the end restores the global post object after a secondary query: leave it out and any code after the loop sees the last listing instead of the real page.
The static leading segment, and the collision it prevents
The single most important design choice is that the first segment of your rule is a fixed, literal word: hotels, search, listings, whatever names your section. The reason is collisions.
WordPress generates rewrite rules for pages that match almost anything, including patterns like ^([^/]+)/?$ for a top-level page. If your rule is also ^([^/]+)/([^/]+)/?$ with no literal prefix, it overlaps with the rules core builds for hierarchical pages, attachments, and other content. With top priority your greedy rule can swallow legitimate page requests; without it, a real page can swallow your route. Either way you get a route that works on the dev box and 404s in production the moment someone publishes a page whose slug looks like one of your segments.
A fixed leading segment sidesteps all of it. ^hotels/([^/]+)/?$ only ever matches paths that start with hotels, so it cannot fight with the page rules for /about/ or /contact/. The corollary: do not create an actual WordPress page or post whose slug is hotels, or the two will compete for the same path. Pick a prefix that is yours and keep it reserved.
Flush the rewrite rules, or nothing happens
Rewrite rules are cached in the database (the rewrite_rules option), not recomputed on every request. Adding add_rewrite_rule() to your code does nothing visible until those cached rules are regenerated. This is the number one reason a correct rule appears broken.
The clean way to flush is on plugin activation, not on init. Calling flush_rewrite_rules() on every request is expensive and a well-known performance mistake:
register_activation_hook( __FILE__, 'te_activate_listing_route' );
function te_activate_listing_route() {
te_register_listing_route();
flush_rewrite_rules();
}During development, the quick equivalent is to visit Settings, Permalinks and click Save Changes, which flushes the rules without changing anything. Do that once after adding or editing a rule and the route comes alive. If you forget, the symptom is always the same: the pretty URL 404s while /?listing_type=hotels&destination=paris still works, because the query variables are registered but the path-to-query mapping has not been rebuilt yet. The foundation here, what a single rewrite rule is and how WordPress turns a request into a query, is worth reading first if any of this feels like magic: see my walkthrough of how add_rewrite_rule works end to end.
Verify it with curl

Once the rules are flushed, test the route from the command line so you are looking at the raw server response, not a cached browser view. A working pretty URL returns 200 and renders your listing content:
curl -s -o /dev/null -w '%{http_code}\n' https://example.com/hotels/paris/
# expect: 200Compare it against the underlying query-string form, which should also return 200 and the same content (it is what the rewrite resolves to internally):
curl -s 'https://example.com/?listing_type=hotels&destination=paris' | headTo prove the variables are actually arriving, dump them temporarily from your template or a template_redirect hook and fetch the pretty URL:
add_action( 'template_redirect', 'te_debug_listing_vars' );
function te_debug_listing_vars() {
if ( get_query_var( 'listing_type' ) ) {
error_log( 'listing_type=' . get_query_var( 'listing_type' ) );
error_log( 'destination=' . get_query_var( 'destination' ) );
}
}Tail the debug log while you curl the route, confirm both values land, then pull the debug hook back out. A 404 on the pretty URL with a working query-string form is always the flush step, not the rule.
Ship it as a must-use plugin
Rewrite routes belong in code that does not vanish when the theme changes. I keep the route, the variable registration, and the activation flush together in one file. As a regular plugin it gets a proper activation hook (which is where the flush should live); dropped into mu-plugins it loads automatically and you flush once via Settings, Permalinks.
<?php
/**
* Plugin Name: TE Pretty URLs
* Plugin URI: https://techearl.com/wordpress-custom-url-structure-query-vars
* Description: Maps a clean multi-segment URL to custom query variables and drives a listing query from them.
* Version: 1.0.0
* Author: Ishan Karunaratne
* Author URI: https://techearl.com
* License: GPL-2.0-or-later
* Text Domain: te-pretty-urls
*/
add_action( 'init', 'te_register_listing_route' );
function te_register_listing_route() {
add_rewrite_rule(
'^hotels/([^/]+)/?$',
'index.php?listing_type=hotels&destination=$matches[1]',
'top'
);
}
add_filter( 'query_vars', 'te_register_listing_vars' );
function te_register_listing_vars( $vars ) {
$vars[] = 'listing_type';
$vars[] = 'destination';
return $vars;
}
register_activation_hook( __FILE__, 'te_activate_listing_route' );
function te_activate_listing_route() {
te_register_listing_route();
flush_rewrite_rules();
}That is the complete pattern: a static-prefixed regex that captures the dynamic segments, the variables whitelisted so WordPress carries them through, and an activation flush so the route is live. Everything else (the pre_get_posts filter, the custom template) reads those variables with get_query_var() and does the actual work.
See also
- How add_rewrite_rule works in WordPress: the foundation for this article, a single rewrite rule from regex to query, before you stack multiple segments on top
- Build a custom WordPress REST API endpoint: when you want the same routing instinct but for a JSON response instead of an HTML page
- Clean up wp_head in WordPress: the companion must-use plugin pattern for trimming the default head output once your custom routes are in place
- How to optimize WooCommerce: where a faceted listing route pays off, and where the real query cost lives on a store
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
- get_query_var(): WordPress Developer Referencedeveloper.wordpress.org





