Register Custom WordPress Rewrite Rules with add_rewrite_rule and add_rewrite_tag

WordPress’s URL routing is built on a rewrite rules table stored in the wp_options table under the key rewrite_rules. When a request arrives, WordPress loops through this table matching the URL path against regular expression patterns until it finds a match, then maps the captured groups to query variables. The entire system is generated by WP_Rewrite and flushed to disk when you click “Save Changes” on the Permalinks settings page. As a plugin or theme developer you can add custom rules to this table with add_rewrite_rule() — matching arbitrary URL patterns to internal WordPress query strings — and expose custom query variables with add_rewrite_tag() combined with add_query_var(). Common use cases include: custom URL structures for CPTs that don’t fit the standard rewrite slug pattern, API-style endpoints like /api/v1/items/42, and vanity URLs for search or filter pages. This article shows the complete pattern from registration to query handling, including the critical step of flushing rewrite rules only on activation — not on every page load.

Problem: You need a custom URL structure — for example /portfolio/category/item-slug/ or /api/products/42 — that WordPress's default rewrite system does not generate automatically.

Solution: Register a custom rewrite rule with add_rewrite_rule(), expose any new query variables with add_rewrite_tag(), and flush rules on plugin activation — never on every request.

Complete example — a URL structure /docs/<section>/<topic>/ that maps to a custom post type query:

<?php
// 1. Register the rewrite rule on init
add_action( 'init', 'register_docs_rewrite_rules' );

function register_docs_rewrite_rules() {
    // Match /docs/section-slug/topic-slug/
    add_rewrite_rule(
        '^docs/([^/]+)/([^/]+)/?$',
        'index.php?post_type=doc&doc_section=$matches[1]&name=$matches[2]',
        'top'  // 'top' = checked before WordPress's own rules
    );

    // Match /docs/section-slug/ (section archive)
    add_rewrite_rule(
        '^docs/([^/]+)/?$',
        'index.php?post_type=doc&doc_section=$matches[1]',
        'top'
    );
}

// 2. Register the custom query variable so WordPress won't strip it
add_filter( 'query_vars', 'add_docs_query_vars' );

function add_docs_query_vars( $vars ) {
    $vars[] = 'doc_section';
    return $vars;
}

// 3. Use the query variable in a template or pre_get_posts
add_action( 'pre_get_posts', 'filter_docs_by_section' );

function filter_docs_by_section( $query ) {
    $section = get_query_var( 'doc_section' );
    if ( $section && $query->is_main_query() && ! is_admin() ) {
        $query->set( 'post_type', 'doc' );
        $query->set( 'tax_query', [ [
            'taxonomy' => 'doc_category',
            'field'    => 'slug',
            'terms'    => sanitize_key( $section ),
        ] ] );
    }
}

// 4. Flush rules on plugin/theme activation ONLY — not on every request
register_activation_hook( __FILE__, function () {
    register_docs_rewrite_rules();
    flush_rewrite_rules();
} );

register_deactivation_hook( __FILE__, function () {
    flush_rewrite_rules();
} );

NOTE: flush_rewrite_rules() is an expensive operation — it regenerates the entire rules table and writes it to the database. Never call it on init or any hook that fires on every page load. The correct pattern is to call it only on plugin activation and deactivation hooks, or to ask the user to visit Settings → Permalinks and click Save after enabling a new rule.