TechEarl

Add Custom Endpoints to WordPress URLs (add_rewrite_endpoint)

How add_rewrite_endpoint() bolts a /reviews/ segment onto the end of an existing WordPress permalink, what the EP_* masks scope it to, and how to read the value without tripping the empty-string gotcha.

Ishan Karunaratne⏱️ 10 min readUpdated
Share thisCopied
How to add a custom endpoint to a WordPress URL with add_rewrite_endpoint(): register a /reviews/ segment, scope it with an EP_ mask, read it via get_query_var(), and render a template on template_redirect.

To bolt a /reviews/ segment onto the end of every post URL (so /my-post/ also answers at /my-post/reviews/), register one endpoint at init and flush the rewrite rules once:

php
add_action( 'init', function () {
    add_rewrite_endpoint( 'reviews', EP_PERMALINK );
} );

That single call is the whole registration. It does not build a standalone rewrite rule from scratch; it appends reviews to the rules WordPress already generates for the permalink structure you have. After flushing (more on that below), /my-post/reviews/ resolves to the same post, and the value after reviews lands in a query var you can read.

What an endpoint is, and how it differs from a rewrite rule

A rewrite rule is a standalone regex-to-query mapping: you match a URL pattern and tell WordPress which query vars to set. add_rewrite_rule() is the right tool when the URL has no relationship to an existing post or page, for example a custom listing at /events/2026/.

An endpoint is narrower and, for the common case, far less code. add_rewrite_endpoint() registers a trailing segment that gets stitched onto the end of existing rules for whatever structure you target. You are not describing a URL; you are describing a suffix that attaches to URLs WordPress already understands. The post-resolution logic, the main query, the is_single() context, all of that still fires as normal. The endpoint just rides along and exposes its value.

Concretely, with EP_PERMALINK the endpoint attaches to single-post permalinks, so /my-post/reviews/ is recognized, /my-post/reviews/5/ is recognized, and the post still loads underneath. You did not write a regex for the post slug; WordPress already had one, and the endpoint extended it.

That is the mental model: a rewrite rule is a new sentence, an endpoint is a clause you append to sentences that already exist.

The EP_* masks: where the endpoint attaches

The second argument to add_rewrite_endpoint() is an endpoint mask, a bitmask of EP_* constants that scopes which URL families the endpoint hangs off. Pass the wrong one and your endpoint is registered against URLs that never get hit. The masks you reach for most:

MaskAttaches the endpoint to
EP_PERMALINKSingle post permalinks (/my-post/reviews/)
EP_PAGESPages (/about/reviews/)
EP_ROOTThe site root (/reviews/)
EP_PERMALINK | EP_PAGESBoth single posts and pages
EP_ALLEvery place WordPress supports an endpoint

There are more (EP_CATEGORIES, EP_TAGS, EP_AUTHORS, EP_DATE, EP_SEARCH, EP_COMMENTS, EP_ATTACHMENT, and so on), and they OR together. If you want the endpoint on posts and pages, combine the two masks with a bitwise OR:

php
add_action( 'init', function () {
    add_rewrite_endpoint( 'reviews', EP_PERMALINK | EP_PAGES );
} );

EP_ALL is the catch-all, and it is tempting, but I avoid it unless I genuinely need the endpoint everywhere. A broad mask generates more rewrite rules than you need and makes the rewrite array harder to reason about when something else misbehaves. Pick the narrowest mask that covers the URLs you actually want the endpoint on.

The constants themselves are defined in WordPress core (wp-includes/rewrite.php); they are plain integers, which is why the OR works.

Reading the endpoint value (and the empty-string gotcha)

Once the rules are flushed, the segment after your endpoint name is available as a query var of the same name:

php
$value = get_query_var( 'reviews' );

For /my-post/reviews/5/, get_query_var( 'reviews' ) returns '5'. So far so obvious. The trap is the bare /my-post/reviews/ case with nothing after it.

When an endpoint is present in the URL but carries no value, WordPress sets the query var to an empty string, not to a missing or false value. That matters because the natural guard most people write is wrong:

php
// WRONG: an empty string is falsy, so the bare /my-post/reviews/ URL slips through.
if ( get_query_var( 'reviews' ) ) {
    // never runs for /my-post/reviews/ with no trailing value
}

An empty string is falsy in PHP, so that if is false exactly when the endpoint is present but valueless, which is usually the case you most want to handle (think /account/orders/ with no order ID). The query var being an empty string is the signal that the endpoint fired. Test for presence, not truthiness:

php
add_action( 'template_redirect', function () {
    // get_query_var() returns false when the var was never set,
    // and '' (empty string) when the endpoint is present but has no value.
    if ( false !== get_query_var( 'reviews', false ) ) {
        // The /reviews/ endpoint is on this URL. Handle it.
    }
} );

Passing false as the second argument to get_query_var() makes the default explicit: you get false only when the var was never registered or set, and an empty string when the endpoint matched without a value. Using isset() on the global $wp_query->query_vars['reviews'] is the other reliable check. Either way, the rule is: do not use a plain truthy test on an endpoint query var.

Rendering a template for the endpoint

Registering the endpoint and reading its value is half the job. The other half is doing something on those URLs. There are two clean hooks for it.

template_redirect runs after the main query is set up but before the theme template loads, which makes it the right place to take over output entirely (render JSON, stream a file, print a stripped-down view) and exit:

php
function te_render_reviews_endpoint() {
    if ( false === get_query_var( 'reviews', false ) ) {
        return;
    }

    // Own the response from here. The main post is already queried,
    // so get_queried_object() gives you the post this endpoint hangs off.
    $post = get_queried_object();

    header( 'Content-Type: text/html; charset=utf-8' );
    echo '<h1>Reviews for ' . esc_html( get_the_title( $post ) ) . '</h1>';
    // ... render the reviews view ...

    exit;
}
add_action( 'template_redirect', 'te_render_reviews_endpoint' );

If instead you want the endpoint to use a real theme template (so it inherits the header, footer, and styling), filter template_include and point it at a file:

php
function te_reviews_template( $template ) {
    if ( false !== get_query_var( 'reviews', false ) ) {
        $custom = locate_template( 'single-reviews.php' );
        if ( $custom ) {
            return $custom;
        }
    }
    return $template;
}
add_filter( 'template_include', 'te_reviews_template' );

template_redirect is for "I am taking over this response." template_include is for "the theme renders this, but with my template file." Reach for the second when you want the surrounding chrome, the first when you do not.

Flush the rewrite rules (once)

New rewrite rules, endpoints included, do not take effect until WordPress regenerates its cached rule array. The lazy way to do it during development is to visit Settings → Permalinks in the admin and hit Save, which calls flush_rewrite_rules() for you. Until you do, /my-post/reviews/ will 404 even though your add_rewrite_endpoint() call ran.

Do not call flush_rewrite_rules() on init on every request. It is an expensive operation that rewrites an option in the database, and running it on each page load is a real performance problem. In a plugin, flush on activation only, and register the endpoint on init so the rule exists for the flush to capture:

php
function te_register_reviews_endpoint() {
    add_rewrite_endpoint( 'reviews', EP_PERMALINK );
}
add_action( 'init', 'te_register_reviews_endpoint' );

function te_flush_reviews_endpoint() {
    te_register_reviews_endpoint();
    flush_rewrite_rules();
}
register_activation_hook( __FILE__, 'te_flush_reviews_endpoint' );

The activation callback registers the endpoint first, then flushes, so the freshly added rule is included in the regenerated array. Pair it with a matching register_deactivation_hook that just calls flush_rewrite_rules() if you want the rule gone cleanly when the plugin is disabled. This is the same flush discipline any custom rewrite needs; see the foundation article linked below for the full treatment.

Verify it with curl

Once registered and flushed, check the endpoint resolves instead of 404ing. The fastest confirmation is the status code:

bash
curl -s -o /dev/null -w '%{http_code}\n' https://example.com/my-post/reviews/
# expect: 200

A 404 here almost always means you have not flushed (visit Settings → Permalinks and save), or the mask does not cover the URL family you are testing (you registered EP_PAGES but hit a post permalink). To see the endpoint value actually arriving, temporarily echo it from your template_redirect callback and fetch the body:

bash
curl -s 'https://example.com/my-post/reviews/5/'

If the bare /my-post/reviews/ 200s but your handler does nothing, that is the empty-string gotcha from above: your guard is testing truthiness instead of presence.

Real-world uses

The pattern shows up wherever you want a sub-view that belongs to an existing piece of content rather than a brand-new URL space:

  • Account sub-pages. WooCommerce's My Account area is built on endpoints: /my-account/orders/, /my-account/edit-address/, /my-account/downloads/ are all endpoints hung off the account page, each rendering a different tab without a separate page in the admin.
  • A print or plain variant of a post. /my-post/print/ renders the same content through a stripped template with no nav and no sidebar, driven entirely by template_include keying off the endpoint.
  • Tabs on a single object. /team-member/jane/ and /team-member/jane/projects/ and /team-member/jane/talks/ are one post with endpoint-driven tabs, no duplicate content, no extra pages to manage.

In every one of these, an endpoint is the right tool precisely because the URL is an extension of content WordPress already routes. If your URL has no underlying post or page, that is when you drop down to a full rewrite rule instead.

See also

Sources

Authoritative references this article was fact-checked against.

TagsWordPressPHPRewrite RulesPermalinksEndpoints

Found this useful? Pass it on.

Copied

Ishan Karunaratne

Tech Architect · Software Engineer · AI/DevOps

Tech architect and software engineer with 20+ years building software, Linux systems, and DevOps infrastructure, and lately working AI into the stack. Currently Chief Technology Officer at a healthcare tech startup, which is where most of these field notes come from.

Keep reading

Related posts