When building websites, sometimes we need to display custom data in the database, which is not a post, page, or a custom post type. In this case, we will create a custom URL for users to access and see the information we want to display. This URL is usually called a virtual page because it is not managed in the WordPress admin area.

The example of virtual pages can be seen on e-commerce sites. After the customer places an order, the website will generate an invoice for customers to view order details. They also can pay or print the invoice to PDF and download it to their computer.

At Meta Box, we also use virtual pages to display the changelog of the plugins. For example, to view the Meta Box Builder changelog, visit the following URL:


Apparently, invoice or changelog has custom content, but they are not a page or a custom post type in WordPress. They are created based on the specific needs of each website.

How to Create a Virtual Page in WordPress

How to create a virtual page

There are a few ways to create a virtual page in WordPress, depending on how you want to generate the URL for that page. In this article, I will show you three ways to create virtual pages based on the permalink structure:

  • A virtual page with a simple URL domain.com/?invoice_id=123.
  • A virtual page that appends to an existing link, such as the link to the changelog of the Meta Box plugins: https://metabox.io/plugins/meta-box-builder/changelog/. We will need to use the rewrite endpoint API.
  • A virtual page that has a custom link structure domain.com/download/123/. We will use custom rewrite rules.

Let’s see how to implement each method below.

Creating a virtual page with a simple URL

This is the simplest type of virtual page since you do not have to work with custom rewrite rules. You just need to get the value of the variable invoice_id on the URL and display the template based on that value.

Note that the variable on the URL must not be the same as the existing WordPress query variables.

To get a template for that URL, we will hook into init as follows:

add_filter( 'init', function( $template ) {
    if ( isset( $_GET['invoice_id'] ) ) {
        $invoice_id = $_GET['invoice_id'];
        include plugin_dir_path( __FILE__ ) . 'templates/invoice.php';
} );

We use the init hook instead of template_include so that WordPress does not have to query the database to retrieve messages. Therefore, speed will be faster.

The template file is located in the templates directory of the plugin. If you put it in another directory or in the add, change the path to that file.

After including the file, we must run die so that WordPress does not execute queries and other tasks (which possibly output to the screen).

This method is very fast and convenient. However, its downside is that the URL is not pretty.

Creating a virtual page with rewrite endpoint API

The Rewrite endpoint API is a part of the rewrite rules API in WordPress, which helps us add a suffix (endpoint) into a link structure already available.

For example, at Meta Box, we create a custom post type product for plugins and each plugin have the following URL structure:


With the endpoint, we can create a URL for the changelog as follows:


Here /changelog/ is added at the end of an existing URL and creates a custom URL.

This is very handy because it makes the URL structured and easy to understand. Besides, it is quite simple.

To add an endpoint, we use the add_rewrite_endpoint() function as follows:

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

And load the template as follows:

add_action( 'template_redirect', function() {
    global $wp_query;
    if ( ! is_singular( 'product' ) || ! isset( $wp_query->query_vars['changelog'] ) ) {
    include plugin_dir_path( __FILE__ ) . 'templates/changelog.php';
} );

We use the template_redirect hook instead of init as we did before because we have to check whether the page being loaded is a single product page. Note that when we add an endpoint to a URL, all template tags of WordPress still work. This is very useful when checking the condition of the page.

Similar to the first method, we also include a template file from the templates folder of the plugin and die so that WordPress does not have to perform extra actions.

Using endpoint is quite convenient for content types that relate to the available content. However, it is limited to existing URL structures and cannot create custom URL structures. To do that, let’s see the third one below.

Creating a virtual page with rewrite endpoint API

Creating a virtual page using custom rewrite rules

Suppose we need to create a page to download the invoice domain.com/download/123, where 123 is the ID of the order. We will need to do the following steps:

Create a custom rewrite rules

add_filter( 'generate_rewrite_rules', function ( $wp_rewrite ){
    $wp_rewrite->rules = array_merge(
        ['download/(\d+)/?$' => 'index.php?dl_id=$matches[1]'],
} );

The filter generate_rewrite_rules is used to add a custom rewrite rule to the list of available WordPress rules (stored in $wp_rewrite->rules). This list is an array, and to add a rule, we simply add an element to that array.

A rule is defined by two components:

  • Rewrite rule: is declared as a regular expression download/(\d+)/?$
  • The parameters when the WordPress parse rule: index.php?dl_id=$matches[1]. WordPress uses the function preg_match() so the parameters are parsed similar to this function. The parameter $matches[1] is the value in the first bracket of the rewrite rule, ie the \d+ part. The value of this parameter is passed to the custom variable dl_id.

Adding a custom query var to WordPress

After parsing the rewrite rule as above, the value of the invoice ID is stored in the dl_id variable. To make WordPress understand this variable, we need to declare it as follows:

add_filter( 'query_vars', function( $query_vars ){
    $query_vars[] = 'dl_id';
    return $query_vars;
} );

This code will add dl_id to the list of WordPress query vars.

Load template

The final step is loading a template for the download page. This is the code:

add_action( 'template_redirect', function(){
    $dl_id = intval( get_query_var( 'dl_id' ) );
    if ( $dl_id ) {
        include plugin_dir_path( __FILE__ ) . 'templates/download.php';
} );

We get the invoice ID by the function get_query_var(). Since the dl_id the variable was declared in the previous step, WordPress understands and derives it. If your URL is download.com/download/123 then the value of dl_id is 123.

Then we load the corresponding template file and die so that WordPress does not handle the next actions.

Compared to the previous method, this last approach is more complex. However, this is the only way you can create your own URL.

You might ask: How do I create a custom URL like domain.com/my-custom-url?

Well, it’s not hard to do that, try the code below:

add_filter( 'generate_rewrite_rules', function ( $wp_rewrite ){
    $wp_rewrite->rules = array_merge(
        ['my-custom-url/?$' => 'index.php?custom=1'],
} );
add_filter( 'query_vars', function( $query_vars ){
    $query_vars[] = 'custom';
    return $query_vars;
} );
add_action( 'template_redirect', function(){
    $custom = intval( get_query_var( 'custom' ) );
    if ( $custom ) {
        include plugin_dir_path( __FILE__ ) . 'templates/custom.php';
} );


Creating a virtual page with a custom URL is a very common thing in plugins. With the 3 ways above, hopefully, you can create virtual pages of your choice. If you have other methods, please share them with us in the comments section.

23 thoughts on “How to Create a Virtual Page in WordPress

    1. That's partly correct. template_include runs *after* WordPress performed query for posts. So, if you don't need to use WordPress query, using template_redirect will have a better performance. In the article, I ran "die" to stop all further actions, which is for the exact purpose.

    2. It's not recommended to use this hook to load a template file that you can do in your theme. However, in this case, it's fine to load a template file for a virtual page, which does not exist in WordPress.

  1. Having just tested the first example myself, I found that literally any URL containing "?invoice_id" will redirect to the specified template. I know you gave reasons for why that first example is "least desirable", but this effect makes it undesirable for completely different reasons, does it not?

    1. Hello, did you add "die" or "exit" in the code? Without that, WordPress will continue parse other query vars and set the template. The code in the post is the code that I'm using for this website and it works.

  2. I just realised my previous comment was confusing. Where I used the word "redirect", what I really meant was "include" - i.e. any URL will include the invoice.php template file regardless of the exact URL path. As long as it has "?invoice_id" at the end, it will redirect.

    If I understand the code correctly, this behaviour is not surprising, because the template is beling included based solely on the condition "if ( isset( $_GET['invoice_id'] ) )". That would evaluate to true regardless of what the URL path was.

    For clarity, I'm talking about the very first example, the one you suggest people NOT use because it does not have "pretty" URLs.

  3. @Marro: Yes, that's right! That's why this method is very simple and should be only used in some specific situations. To keep the URL non-public, you should create a secret query param that only you know it.

  4. I didn't even get that far, I just copied the snippet in my functions.php and added the custom template in my theme, the error happens on plain front page, with now queries.

  5. FYI, you have a typo in your code - where you return $public_query_vars, that variable isn't set - use return $query_vars instead.

  6. I am using the "Creating a virtual page using custom rewrite rules" technique for virtual pages, and it works. Elsewhere in my theme, I am trying to use "is_page_template('my-virtual-page-template.php')", and it does NOT work for my virtual pages, even though they definitely are using this template. Have you run into this issue and/or do you have a solution?

  7. Hi, this is very helpful as I need to create custom print friendly pages with plain text ACF field for our news items, and ideally something like /site/name-of-story/print and then load custom temlpate that pulls in the custom clean text fields without the HTML bold/italics/links or images. So, I think one of your first couple of examples with the rewrite endpoint API would work for that. So, woo and hoo. Thanks ! 🙂

  8. I'm using the "Creating a virtual page with rewrite endpoint API" approach, but only want these virtual pages on child pages. Is that possible?

    Example... fitness studio with studios as post type. Top level pages are the City, child pages are the locations, and we want /pricing and /schedule endpoints set up for those child pages.

    So url might be domain.com/new-york/uptown/pricing

    Any tips would be greatly appreciated!

  9. This is excellent! For a modal, the first example worked great, and it kept everything simple. Much appreciated.

  10. Any suggestion how to work within comment form and comments on virtual page? I'm using second example.
    Everything working as expected, but when I send form, page reload to virtual "parent page".


  11. Hi, excellent article. One question I have is, how do you return a response code like 404 or 301 if the ID is invalid or doesn't exist? I'm not quite sure how to hook WP before the headers are written to the output stream.

Leave a Reply

Your email address will not be published. Required fields are marked *