TechEarl

WordPress Rewrite Rules: Query Variables and Virtual Pages

Build WordPress routes with fixed prefixes, validated query variables and deliberate status codes. Includes a complete virtual page and one-time flush checks.

Ishan Karunaratne⏱️ 6 min readUpdated
Share thisCopied
How to add a custom rewrite rule in WordPress: the add_rewrite_rule call on init, registering the custom query var with the query_vars filter, reading it with get_query_var, loading a template, and flushing the rewrite rules once.

A WordPress rewrite rule maps a path to query variables. A working custom route also needs those variables registered, a handler that validates the requested resource, and a deliberate HTTP status. Flush the stored rules once after changing the rule set.

php
add_action( 'init', function () {
    add_rewrite_rule(
        '^te-guide/([a-z0-9-]+)/?$',
        'index.php?te_guide=$matches[1]',
        'top'
    );
} );
add_filter( 'query_vars', function ( $vars ) {
    $vars[] = 'te_guide';
    return $vars;
} );

This registers routing syntax only. It does not create content for every matching slug or automatically authorize a private resource.

The regex, the rewrite target, and the position

The request path normally has no leading slash. ^te-guide/ reserves a literal prefix; the capture accepts a single lowercase slug; /?$ allows an optional trailing slash and prevents matching extra segments. Single-quoted PHP preserves $matches[1] for WordPress to substitute later.

'top' places the rule before core's general rules; it does not make your rule the only custom rule at that position. Register specific rules before broader ones. A broad top-level ^([^/]+)/([^/]+)/?$ can capture ordinary pages and taxonomy paths. Reserve a prefix you own instead.

The index.php?... target is an internal query, not an HTTP redirect. The URL in the browser remains unchanged.

Register the query var

Add each custom public variable through query_vars, or WordPress will discard it during request parsing. Read it with get_query_var() after parsing. Public query variables can also arrive through a query string, so validate the value rather than trusting that it necessarily came from your regex.

php
$slug = (string) get_query_var( 'te_guide', '' );

A query variable is routing input, not evidence the requested resource exists.

Multiple URL segments

For a location-based list, keep a fixed prefix and capture only the meaningful values:

php
add_action( 'init', function () {
    add_rewrite_rule(
        '^te-listings/(hotels|restaurants)/([a-z0-9-]+)/?$',
        'index.php?te_listing_type=$matches[1]&te_destination=$matches[2]',
        'top'
    );
} );
add_filter( 'query_vars', function ( $vars ) {
    $vars[] = 'te_listing_type';
    $vars[] = 'te_destination';
    return $vars;
} );

/te-listings/hotels/paris/ yields hotels and paris. The handler must still validate that destination against real supported data and construct the intended query. Do not manufacture a 200 landing page for every arbitrary string that matches the regex. Use query APIs or prepared SQL for data lookups, and escape values when rendering.

Route to existing content or custom query

When a route is another address for an existing WordPress post, use its actual post type and identity in the rewrite target where possible. WordPress can then establish the normal queried object and template behavior:

php
add_action( 'init', function () {
    add_rewrite_rule(
        '^te-article/([a-z0-9-]+)/?$',
        'index.php?post_type=post&name=$matches[1]',
        'top'
    );
} );

This is an illustration of a post-backed route. Core or an SEO plugin may redirect the alias to the post's canonical permalink. Decide whether that is the desired behavior; do not disable canonical redirects globally to make an alias stick. For a permanent public URL scheme, a registered post type's rewrite settings are often a better fit than adding parallel aliases.

For a list query, select the data explicitly rather than assuming your custom variables alter WP_Query by themselves. Restore post data after a secondary loop. A template filter only selects a file; it does not by itself validate a resource, clear a 404 flag, or change HTTP status.

Virtual page with no database post

The complete must-use plugin below owns one generated endpoint, /te-status/. It returns a small status document without requiring a wp_posts row for that URL. It still boots WordPress and its normal request lifecycle; “virtual” does not mean zero database work.

Save it as wp-content/mu-plugins/te-virtual-status.php:

php
<?php
/**
 * Plugin Name: TE Virtual Status
 * Plugin URI: https://techearl.com/wordpress-add-rewrite-rule
 * Description: One explicitly owned virtual status route.
 * Version: 2.0.0
 * Author: Ishan Karunaratne
 * Author URI: https://techearl.com
 * License: GPL-2.0-or-later
 */
defined( 'ABSPATH' ) || exit;

add_action( 'init', function () {
    add_rewrite_rule( '^te-status/?$', 'index.php?te_virtual=status', 'top' );
} );
add_filter( 'query_vars', function ( $vars ) {
    $vars[] = 'te_virtual';
    return $vars;
} );

add_action( 'template_redirect', function () {
    if ( 'status' !== get_query_var( 'te_virtual' ) ) {
        return;
    }
    // Do not serve arbitrary paths merely because a query parameter was added.
    $path = wp_parse_url( wp_unslash( $_SERVER['REQUEST_URI'] ?? '' ), PHP_URL_PATH );
    $expected = wp_parse_url( home_url( '/te-status/' ), PHP_URL_PATH );
    if ( untrailingslashit( (string) $path ) !== untrailingslashit( (string) $expected ) ) {
        return;
    }
    $method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
    if ( ! in_array( $method, array( 'GET', 'HEAD' ), true ) ) {
        status_header( 405 );
        header( 'Allow: GET, HEAD' );
        exit;
    }
    global $wp_query;
    $wp_query->is_404 = false;
    status_header( 200 );
    nocache_headers();
    header( 'Content-Type: text/html; charset=' . get_option( 'blog_charset' ) );
    if ( 'HEAD' === $method ) {
        exit;
    }
    echo '<!doctype html><html lang="en"><head><meta charset="utf-8">';
    echo '<title>Status</title></head><body><h1>OK</h1><p>Generated at ';
    echo esc_html( gmdate( 'c' ) );
    echo '</p></body></html>';
    exit;
}, 1 );

Priority 1 lets this exact owned handler complete before ordinary canonical redirect handling. The endpoint reports that WordPress reached this handler; it is not a comprehensive database, queue or external-service health check. Add those checks deliberately if that is what monitoring requires, and keep secrets out of the response.

Choose status codes and templates deliberately

A generated page is not automatically a 404 or automatically a valid 200. The main query, registered variables, existing content and other hooks affect that result. For an existing supported resource, send 200 before output. For a missing resource, keep or set the 404 state and send 404. Redirect only when there is a real replacement.

For a themed virtual page, validate the resource before choosing a template, set the query state/status in an earlier hook, then use template_include to return an existing absolute template path. That template must actually call the theme functions it needs; choosing it does not magically execute the_content() or emit a header/footer. If the template file is missing, fail visibly rather than returning an empty success page.

For a private report, authenticate and check capability before rendering. For JSON integrations, a REST endpoint provides request validation and response handling without recreating those facilities in a frontend route.

Flush the rules once, and never on every request

WordPress stores a compiled rule map in its rewrite_rules option. After adding or changing these rules:

bash
wp rewrite flush
wp rewrite list --match=te-status/ --format=table

Settings → Permalinks → Save Changes is the manual alternative. Must-use plugins do not have an activation lifecycle, so flush manually once after deploying the file. If you remove the file, flush again after the registration code is gone.

For a normal plugin, call its named rule-registration function before flushing in the activation callback. On deactivation, remove its rule registration from the in-memory rule set before rebuilding, or perform the flush after the plugin is inactive. Do not attach flush_rewrite_rules() to every init request.

Verify it with curl

bash
curl -sS -D - https://example.com/te-status/
curl -sSI https://example.com/te-status/
curl -sSI https://example.com/te-status/extra/
curl -sSI 'https://example.com/definitely-missing/?te_virtual=status'
curl -sS -o /dev/null -w '%{http_code}\n' -X POST https://example.com/te-status/

Expect the exact route to return 200, HEAD to have no body, unknown paths to remain genuine missing pages, and POST to return 405. Also test ordinary posts, pages and taxonomy archives to catch collisions. Inspect both status and body; a visually correct page can still have the wrong status.

If the rule is absent, check plugin loading and flush the rules. If it is present but the value is empty, inspect the query-var registration. If a different rule wins, inspect ordering and the fixed prefix. Continue with rewrite troubleshooting or rewrite endpoints when adding a sub-view to an existing URL is the actual task.

Sources

Authoritative references this article was fact-checked against.

TagsWordPressPHPRewrite APIPermalinksRouting

Found this useful? Pass it on.

Copied

Ishan Karunaratne

Systems and Network Architect · Chief Technology Officer

Systems and network architect and Chief Technology Officer with more than two decades designing, building, and running production software, cloud and network architecture, Linux systems, and the bare metal underneath them, and lately working AI into the stack. A US Army veteran who served in Operation Iraqi Freedom. What I write here is drawn from the full arc of that work, across architecture, engineering, and operations, not any single job.

Keep reading

Related posts