A virtual page is a URL that serves a complete, working page even though there is no post, page, or any other row backing it in the database. You register a rewrite rule that points the path at a custom query var, then catch that query var on template_redirect, send a 200 status header yourself, render whatever markup you want, and exit. WordPress never looks for a post, so there is nothing to look up and nothing to 404 on.
Here is the whole thing as a must-use plugin. It serves a live status page at /te-status/ that exists nowhere in wp_posts:
<?php
/**
* Plugin Name: TE Virtual Page
* Plugin URI: https://techearl.com/wordpress-virtual-pages-rewrite
* Description: Serves a working page at a URL that has no post in the database.
* Version: 1.0.0
* Author: Ishan Karunaratne
* Author URI: https://techearl.com
* License: GPL-2.0-or-later
* Text Domain: te-virtual-page
*/
add_action( 'init', 'te_virtual_register_rule' );
function te_virtual_register_rule() {
add_rewrite_rule( '^te-status/?$', 'index.php?te_virtual=status', 'top' );
}
add_filter( 'query_vars', 'te_virtual_register_var' );
function te_virtual_register_var( $vars ) {
$vars[] = 'te_virtual';
return $vars;
}
add_action( 'template_redirect', 'te_virtual_render' );
function te_virtual_render() {
if ( 'status' !== get_query_var( 'te_virtual' ) ) {
return;
}
status_header( 200 );
nocache_headers();
header( 'Content-Type: text/html; charset=utf-8' );
echo '<!doctype html><html><head><meta charset="utf-8">';
echo '<title>Status</title></head><body>';
echo '<h1>OK</h1>';
echo '<p>Generated at ' . esc_html( gmdate( 'c' ) ) . '</p>';
echo '</body></html>';
exit;
}Drop that in wp-content/mu-plugins/te-virtual-page.php, then visit Settings → Permalinks once and click Save to flush the rewrite rules (more on that below). Hit /te-status/ and you get a real 200 page with live, generated content, and SELECT * FROM wp_posts WHERE post_name = 'te-status' returns nothing.
The three moving parts
The mechanism is small but every piece earns its place.
1. The rewrite rule maps the URL to a query var. add_rewrite_rule() takes a regex for the request path, a destination string that always starts with index.php, and a position. The destination here is index.php?te_virtual=status: that is not a redirect, it is an internal rewrite that tells WordPress "when the path matches, set te_virtual to status and carry on parsing the request." The 'top' position prepends the rule so it is evaluated before WordPress's own rules (the canonical ones for posts, pages, dates, and so on), which keeps your path from being swallowed by a broader built-in pattern.
2. The query var has to be registered. WordPress drops any query var it does not recognise, as a guard against arbitrary query pollution. If you skip the query_vars filter, get_query_var( 'te_virtual' ) comes back empty no matter how clean your rewrite rule is, and you will spend an hour blaming the regex. Adding te_virtual to the array on the query_vars filter is what makes it readable later.
3. template_redirect is where you take over. This hook fires after WordPress has parsed the request and decided what it thinks you asked for, but before it loads a template. That is the right seam: the query vars are populated so you can read yours, and nothing has been sent to the browser yet, so you are still free to set the status and headers.
Why there is no post, and why that 404s by default
This is the part that trips people up, so it is worth being precise.
When WordPress parses /te-status/, your rewrite rule sets te_virtual=status but sets nothing else. There is no p, no page_id, no name, no category_name. So the main query (WP_Query) runs, finds no posts to show, and concludes the request matched nothing real. It sets $wp_query->is_404 = true, and unless something intervenes, template-loader.php then loads 404.php and the response goes out with a 404 Not Found status line.
So the default outcome for a URL with no backing post is exactly a 404. That is correct behaviour for a typo'd permalink; it is the wrong behaviour for a page you are deliberately generating in code. The fix is to step in on template_redirect, before the template loader runs, and tell WordPress that you have got this.
status_header(200) and exit: the actual fix
Two calls do the real work, and leaving out either one breaks it in a different way.
status_header( 200 );
// ... render ...
exit;status_header( 200 ) sets the HTTP response status line to 200 OK. Without it, your page body would still print, but it would go out under the 404 status WordPress already decided on. That is the single most common mistake with virtual pages: the content looks fine in a browser, but curl -I shows HTTP/1.1 404 Not Found, search engines refuse to index it, monitoring tools mark the endpoint down, and you have no idea why because the page clearly "works."
exit is what stops WordPress from continuing past your handler into the template loader. Render your markup and then exit (or die) so template-loader.php never runs, never loads 404.php, and never appends a theme footer you did not ask for. Without the exit, your output prints first and then WordPress dumps the 404 template right after it, giving you a Frankenstein page.
If you want to be belt-and-suspenders about it, you can also clear the 404 flag on the main query before rendering:
add_action( 'template_redirect', 'te_virtual_render' );
function te_virtual_render() {
if ( 'status' !== get_query_var( 'te_virtual' ) ) {
return;
}
global $wp_query;
$wp_query->is_404 = false;
status_header( 200 );
nocache_headers();
// ... render and exit
}In practice, calling status_header( 200 ) and then exit is enough on its own, because you never give the template loader a chance to read is_404. Setting $wp_query->is_404 = false matters more when you are not exiting immediately, for example if you let WordPress load one of your theme templates to render the virtual page (next section). It also keeps anything else hooked later in the request, like analytics or a conditional in the theme header, from seeing a stale 404 flag.
nocache_headers() is optional and situational: include it when the page is dynamic (a status check, a generated dashboard) so caches and the browser do not hold a stale copy. Drop it if the virtual page is effectively static and you want it cached at the edge.
Echoing markup vs loading a template
Echoing the HTML straight from the handler, as in the first example, is fine for a machine endpoint or a tiny status page where pulling in the theme would be overkill. For anything a human looks at, you usually want the page to live inside your theme's header and footer. Two clean ways to do that:
Load a dedicated template file and let it own the markup:
add_action( 'template_redirect', 'te_virtual_render' );
function te_virtual_render() {
if ( 'status' !== get_query_var( 'te_virtual' ) ) {
return;
}
global $wp_query;
$wp_query->is_404 = false;
status_header( 200 );
$template = plugin_dir_path( __FILE__ ) . 'templates/status.php';
if ( file_exists( $template ) ) {
include $template;
}
exit;
}That status.php can call get_header() and get_footer() so the virtual page wears the active theme's chrome, while the body in between is whatever you generate. Because you cleared is_404 and sent the 200, get_header() will not render the "page not found" title that some themes key off the 404 conditional.
The other option, when you want WordPress's own template hierarchy to pick the file, is to filter template_include and point it at your template instead of exiting by hand. That keeps you inside the normal rendering flow (so the the_content filters and enqueued assets all run) at the cost of giving up the hard exit. I reach for the explicit include + exit for endpoints and the template_include filter when the virtual page should feel like a first-class page of the site. Either way, the status_header( 200 ) call is non-negotiable.
When a virtual page is the right tool
The whole point is to serve content at a URL that should not exist as editable content in wp_posts. Cases where I reach for this:
- A status or health page.
/te-status/returning a generated200with a timestamp or a dependency check, for an uptime monitor to poll. It must never be something an editor can accidentally trash or unpublish. - A generated dashboard or report. A page that assembles its content from an API call, a transient, or a custom table at request time. There is no static body to store, so there is no post.
- A dynamic landing page that should not be editable. A campaign or programmatic page whose content is driven entirely by code or the URL itself, where letting it show up in the Pages list would just invite someone to "fix" it.
- A machine endpoint that returns HTML or text rather than JSON. If you want JSON and the full request lifecycle, the REST API is the better tool. See building a custom WordPress REST API endpoint for that path; reach for a virtual page when you specifically want an HTML response at a clean front-end URL.
If a non-developer needs to edit the words on the page, this is the wrong tool. Make a real page. Virtual pages are for content that is generated, not authored.
Flush the rewrite rules (the step everyone forgets)
WordPress caches the compiled rewrite rules in the rewrite_rules option. A new add_rewrite_rule() call does nothing until that cache is regenerated, so a freshly added rule 404s and you assume the regex is wrong when the rule simply has not been registered yet.
To flush: go to Settings → Permalinks and click Save Changes (you do not need to change anything; saving re-flushes). Do this once after adding or editing the rule. Do not call flush_rewrite_rules() on init or on every page load: it is an expensive database write and running it per request is a real performance bug. If you want to automate it, hook it to plugin activation (register_activation_hook) so it runs exactly once when the plugin is switched on. The rewrite-rules mechanism, the regex matching, and the flush lifecycle are covered end to end in my guide to adding a custom rewrite rule in WordPress; this article is the "now render something at that URL" follow-on.
Verify it with curl

A browser will happily show you the body and hide the status code, which is exactly how a virtual page ships looking fine while quietly returning a 404. Check the status line directly:
curl -sI https://example.com/te-status/You want the first line to read HTTP/2 200 (or HTTP/1.1 200 OK), not 404. To see the status and the generated body together:
curl -s -o /dev/null -w '%{http_code}\n' https://example.com/te-status/
# expect: 200
curl -s https://example.com/te-status/
# expect: your generated <h1>OK</h1> markupIf the body is right but the status is 404, you forgot status_header( 200 ) (or something ran after your handler and reset it). If you get a hard 404 with the theme's not-found page, the rewrite rule has not flushed, the query var is not registered, or the regex did not match the path you requested.
See also
- Add a Custom Rewrite Rule in WordPress: the foundation piece, how
add_rewrite_rule()maps URLs to query vars, how regex matching and'top'ordering work, and how the rewrite-rules cache and flushing fit together - Build a Custom WordPress REST API Endpoint: the right tool when you want JSON and the full request lifecycle at a
/wp-json/route, rather than HTML at a front-end URL - Clean Up wp_head in WordPress: trimming the default head output once you are hand-rendering pages and want control over exactly what ships in the markup
Sources
Authoritative references this article was fact-checked against.
- add_rewrite_rule(): WordPress Developer Referencedeveloper.wordpress.org
- template_redirect hook: WordPress Developer Referencedeveloper.wordpress.org
- status_header(): WordPress Developer Referencedeveloper.wordpress.org





