Rewrite rules and permalinks: a practical WordPress guide
Custom URLs are often the difference between a professional WordPress installation and one that feels cobbled together. When you need endpoints beyond posts, pages, and archives—say, a special booking confirmation page, a custom API route, or a branded download link—WordPress's rewrite system is your foundation. We're going to walk through the mechanics of rewrite rules and permalinks, show you how to register custom routes, read query variables, and avoid the common gotchas that catch teams mid-project.
Why rewrite rules matter
At its core, WordPress uses rewrite rules to map a URL slug to a set of query variables. The server receives a request for /my-book/chapter-2/, and the rewrite engine converts that into something like ?post_type=book&chapter=2 under the hood. Without understanding this layer, you'll either hardcode URLs (fragile), build complex custom routing outside WordPress (defeating the purpose), or hit silent failures where your custom endpoints don't work.
The rewrite system is especially critical if you're building multi-tenant platforms, marketplaces, or SaaS features within WordPress. It keeps your code clean, your URLs SEO-friendly, and your data routing predictable.
add_rewrite_rule for custom routes
The function add_rewrite_rule() is your primary tool. It registers a pattern and tells WordPress what query variables to set when that pattern matches.
Basic syntax:
add_rewrite_rule(
$regex, // Pattern to match against the URL
$query, // Query variables to inject
$after // Position in the rules stack
);Let's say we're building a booking system. We want URLs like /bookings/12345/ to load a custom page template with the booking ID available to our code.
add_action( 'init', function() {
add_rewrite_rule(
'^bookings/([0-9]+)/?$',
'index.php?post_type=booking&booking_id=$matches[1]',
'top'
);
} );Here's what's happening:
^bookings/([0-9]+)/?$matches URLs like/bookings/12345/or/bookings/12345. The parentheses capture the ID.$matches[1]references the first captured group (the booking ID).'top'places this rule early in the stack—important for specificity.
If you're building something more complex—say, a multi-level hierarchy like /teams/acme-corp/projects/roadmap/—you can capture multiple groups:
add_rewrite_rule(
'^teams/([a-z0-9-]+)/projects/([a-z0-9-]+)/?$',
'index.php?custom_resource=project&team=$matches[1]&project=$matches[2]',
'top'
);
Notice we're using a custom query variable custom_resource to signal that this is a project page. This gives us flexibility in how we load templates and handle the request later.
Query vars to read values
Simply adding a rewrite rule isn't enough. WordPress has a whitelist of allowed query variables for security reasons. If you try to use &my_custom_var=value without registering it, WordPress silently strips it out.
Register your custom query variables with the query_vars filter:
add_filter( 'query_vars', function( $vars ) {
$vars[] = 'booking_id';
$vars[] = 'team';
$vars[] = 'project';
$vars[] = 'custom_resource';
return $vars;
} );Now, inside your template or plugin, you can retrieve these values:
// In a template file or hooked function
$booking_id = get_query_var( 'booking_id' );
$team = get_query_var( 'team' );
$project = get_query_var( 'project' );
if ( $booking_id ) {
// Load the booking data
$booking = get_post( $booking_id );
// Render your template
}
A real-world pattern: we often use this approach in a template_redirect hook to load custom templates dynamically. If a certain query var is set, we include a specific template file and exit:
add_action( 'template_redirect', function() {
$custom_resource = get_query_var( 'custom_resource' );
if ( 'project' === $custom_resource ) {
include get_template_directory() . '/template-project.php';
exit;
}
} );Flush rules on activation only
Here's a trap we've all fallen into: you add rewrite rules, test locally, and they work. You push to staging, and suddenly the URLs 404. The problem? WordPress caches rewrite rules. They're stored in the database and the .htaccess file. You need to flush them after adding new rules.
The key is flushing only when necessary. Flushing on every page load is wasteful and can cause performance issues under load. Instead, flush when the plugin activates or when you deploy code changes.
// In your main plugin file
register_activation_hook( __FILE__, function() {
// Re-register rewrite rules
add_rewrite_rule(
'^bookings/([0-9]+)/?$',
'index.php?post_type=booking&booking_id=$matches[1]',
'top'
);
// Flush the rewrite rules to the database and .htaccess
flush_rewrite_rules();
} );For theme or plugin development in a team, also set a small flag in your init hook to handle cases where code changes between versions:
add_action( 'init', function() {
$current_version = '1.2.0';
$stored_version = get_option( 'my_plugin_version' );
if ( $current_version !== $stored_version ) {
add_rewrite_rule(
'^bookings/([0-9]+)/?$',
'index.php?post_type=booking&booking_id=$matches[1]',
'top'
);
flush_rewrite_rules();
update_option( 'my_plugin_version', $current_version );
}
} );Always flush on activation. In development, you can manually visit Settings > Permalinks and click Save to trigger a flush without code changes.
Test with Query Monitor
Debugging rewrite rules is difficult without visibility. Query Monitor is invaluable here. It shows you:
- What rewrite rules are registered
- Which rule matched your current request
- What query variables were set
- Whether your custom query var was whitelisted
Install and enable Query Monitor, then visit a URL that should trigger your rule. Open the Query Monitor panel and navigate to the Rewrite Rules section. You'll see the exact regex, what it matches, and the resulting query string.
If your URL isn't being caught, check:
- Is the rule in the list? If not, you haven't flushed yet.
- Is the regex pattern correct? Test it against your URL structure.
- Is the rule positioned early enough? Use
'top'when adding via hooks. - Are there conflicting rules above it that match first?
Query Monitor also shows you the matched rule and the final query vars passed to WordPress, which is perfect for verifying that your custom variables are present and readable via get_query_var().
Common mistakes
- Forgetting to register query vars: Your rewrite rule passes
my_var=value, but you never addedmy_varto thequery_varsfilter. WordPress silently strips it, andget_query_var()returns false. - Not flushing after changes: You update the regex pattern, deploy, and it 404s. The old pattern is still cached. Always flush in a hook or manually.
- Overly broad regex patterns: A pattern like
^(.*)$will match everything and prevent other routes from being evaluated. Be specific. - Using
'bottom'when you need'top': If your custom rule is added after WordPress's built-in rules, the built-in ones match first. Use'top'for high-priority patterns. - Adding rewrite rules outside the
inithook: This is technically possible but fragile. Keep all rewrite logic ininitor plugin activation hooks. - Hardcoding URLs instead of using
add_rewrite_rule(): It might feel faster, but you lose SEO benefits, internal linking breaks on permalink changes, and your code becomes unmaintainable.
Key takeaways
- Use
add_rewrite_rule()in theinithook to map custom URL patterns to query variables. - Always register custom query variables with the
query_varsfilter, or WordPress will strip them for security. - Flush rewrite rules on plugin activation and when version changes, not on every page load.
- Query Monitor is essential for debugging: it shows you matched rules and query vars in real time.
- Be specific with regex patterns and use
'top'priority for custom rules to avoid being shadowed by built-in WordPress routes. - Read values with
get_query_var()in templates or hooked functions, never directly from the URL.






Comments
Loading comments…