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 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.

create virtual pages 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 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 simple URL

This is the simplest type of virtual pages 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 );
} );

Add 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 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 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 parse 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 to 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 variable was declared in the previous step, WordPress understand and derive 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 with us in the comments section.

Also published on Medium.

13 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.

  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.

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.