2022-01-25

How to create a page under a custom post type URL in WordPress

WordPress custom post types are a great way to model your content. Let’s say your company creates all kinds of products, like widgets, gadgets and gizmos. You can create a new new custom post type in WordPress called Products to store these posts. Custom post types also allow you to have specific fields for each of the products, like price or SKU. Every public custom post type in WordPress is assigned an URL structure which consists of a post type name and post slug, for example:

https://example.com/products/widgets https://example.com/products/gadgets https://example.com/products/gizmos

This means that everything under products in the URL is reserved only for Product custom post type posts. But what if you want to create a new page, rather than product under product URL? For example, a page named Promotions and sales, located at the following URL:

https://example.com/products/promotions-and-sales

By default, this is not something you can do. But we can make a few changes to the WordPress Rewrite API to make it possible. Let’s start from beginning and create a new custom post type called Products.

Creating a new custom post type

We can create a CPT called Products with the following code:

function mycompany_register_custom_post_type() { $labels = [ 'name' => _x('Products', 'Post Type General Name', 'mycompany'), 'singular_name' => _x('Product', 'Post Type Singular Name', 'mycompany'), ]; $rewrite = [ 'slug' => 'products', 'with_front' => false, ]; $args = [ 'labels' => $labels, 'supports' => ['title', 'editor'], 'hierarchical' => false, 'public' => true, 'has_archive' => false, 'rewrite' => $rewrite, ]; register_post_type('mycompany_product', $args); } add_action('init', 'mycompany_register_custom_post_type');

The important thing here is to set has_archive to false. This will allow as to use a page as root for the CPT. When our CPT has been created, we can create a new product called Widgets using the WordPress admin interface.

WordPress caches the permalink structure quite heavily. If your CPT permalinks don’t work, visit Permalinks under Settings and click save to flush the permalink cache.

When visiting http://example.com/products/widgets, we should see the following:

Creating a page under CPT archive

Since our CPT doesn’ have an archive, we can create a new page called Products under the https://example.com/products URL.

When we view the page on the frontend, it will look something like this:

Creating a page under CPT permalink

Last, let’s create our page Promotions and Sales under the URL https://example.com/products/promotions-and-sales. Everything looks good in the WordPress admin interface.

But if we try to visit that page on the frontend, we get the following error:

So, why is this? Under the hood, WordPress has something called the Rewrite API.

Rewrite API explained

When you enable pretty permalinks like most sites do, WordPress needs to map any URL you visit into its internal representation. If you visit the URL https://example.com/products/widgets , WordPress actually maps this URL internally into https://example.com/index.php?mycompany_product=widgets . WordPress does this by using a series of regular expressions. You can get a list of them by using the rewrite_rules_array filter. Here’s the list rewrite rules on this site in JSON format:

{ "^wp-json/?$": "index.php?rest_route=/", "^wp-json/(.*)?": "index.php?rest_route=/$matches[1]", "^index.php/wp-json/?$": "index.php?rest_route=/", "^index.php/wp-json/(.*)?": "index.php?rest_route=/$matches[1]", "^wp-sitemap\\.xml$": "index.php?sitemap=index", "^wp-sitemap\\.xsl$": "index.php?sitemap-stylesheet=sitemap", "^wp-sitemap-index\\.xsl$": "index.php?sitemap-stylesheet=index", "^wp-sitemap-([a-z]+?)-([a-z\\d_-]+?)-(\\d+?)\\.xml$": "index.php?sitemap=$matches[1]&sitemap-subtype=$matches[2]&paged=$matches[3]", "^wp-sitemap-([a-z]+?)-(\\d+?)\\.xml$": "index.php?sitemap=$matches[1]&paged=$matches[2]", "category/(.+?)/feed/(feed|rdf|rss|rss2|atom)/?$": "index.php?category_name=$matches[1]&feed=$matches[2]", "category/(.+?)/(feed|rdf|rss|rss2|atom)/?$": "index.php?category_name=$matches[1]&feed=$matches[2]", "category/(.+?)/embed/?$": "index.php?category_name=$matches[1]&embed=true", "category/(.+?)/page/?([0-9]{1,})/?$": "index.php?category_name=$matches[1]&paged=$matches[2]", "category/(.+?)/?$": "index.php?category_name=$matches[1]", "tag/([^/]+)/feed/(feed|rdf|rss|rss2|atom)/?$": "index.php?tag=$matches[1]&feed=$matches[2]", "tag/([^/]+)/(feed|rdf|rss|rss2|atom)/?$": "index.php?tag=$matches[1]&feed=$matches[2]", "tag/([^/]+)/embed/?$": "index.php?tag=$matches[1]&embed=true", "tag/([^/]+)/page/?([0-9]{1,})/?$": "index.php?tag=$matches[1]&paged=$matches[2]", "tag/([^/]+)/?$": "index.php?tag=$matches[1]", "type/([^/]+)/feed/(feed|rdf|rss|rss2|atom)/?$": "index.php?post_format=$matches[1]&feed=$matches[2]", "type/([^/]+)/(feed|rdf|rss|rss2|atom)/?$": "index.php?post_format=$matches[1]&feed=$matches[2]", "type/([^/]+)/embed/?$": "index.php?post_format=$matches[1]&embed=true", "type/([^/]+)/page/?([0-9]{1,})/?$": "index.php?post_format=$matches[1]&paged=$matches[2]", "type/([^/]+)/?$": "index.php?post_format=$matches[1]", "products/[^/]+/attachment/([^/]+)/?$": "index.php?attachment=$matches[1]", "products/[^/]+/attachment/([^/]+)/trackback/?$": "index.php?attachment=$matches[1]&tb=1", "products/[^/]+/attachment/([^/]+)/feed/(feed|rdf|rss|rss2|atom)/?$": "index.php?attachment=$matches[1]&feed=$matches[2]", "products/[^/]+/attachment/([^/]+)/(feed|rdf|rss|rss2|atom)/?$": "index.php?attachment=$matches[1]&feed=$matches[2]", "products/[^/]+/attachment/([^/]+)/comment-page-([0-9]{1,})/?$": "index.php?attachment=$matches[1]&cpage=$matches[2]", "products/[^/]+/attachment/([^/]+)/embed/?$": "index.php?attachment=$matches[1]&embed=true", "products/([^/]+)/embed/?$": "index.php?mycompany_product=$matches[1]&embed=true", "products/([^/]+)/trackback/?$": "index.php?mycompany_product=$matches[1]&tb=1", "products/([^/]+)/page/?([0-9]{1,})/?$": "index.php?mycompany_product=$matches[1]&paged=$matches[2]", "products/([^/]+)/comment-page-([0-9]{1,})/?$": "index.php?mycompany_product=$matches[1]&cpage=$matches[2]", "products/([^/]+)(?:/([0-9]+))?/?$": "index.php?mycompany_product=$matches[1]&page=$matches[2]", "products/[^/]+/([^/]+)/?$": "index.php?attachment=$matches[1]", "products/[^/]+/([^/]+)/trackback/?$": "index.php?attachment=$matches[1]&tb=1", "products/[^/]+/([^/]+)/feed/(feed|rdf|rss|rss2|atom)/?$": "index.php?attachment=$matches[1]&feed=$matches[2]", "products/[^/]+/([^/]+)/(feed|rdf|rss|rss2|atom)/?$": "index.php?attachment=$matches[1]&feed=$matches[2]", "products/[^/]+/([^/]+)/comment-page-([0-9]{1,})/?$": "index.php?attachment=$matches[1]&cpage=$matches[2]", "products/[^/]+/([^/]+)/embed/?$": "index.php?attachment=$matches[1]&embed=true", "robots\\.txt$": "index.php?robots=1", "favicon\\.ico$": "index.php?favicon=1", ".*wp-(atom|rdf|rss|rss2|feed|commentsrss2)\\.php$": "index.php?feed=old", ".*wp-app\\.php(/.*)?$": "index.php?error=403", ".*wp-register.php$": "index.php?register=true", "feed/(feed|rdf|rss|rss2|atom)/?$": "index.php?&feed=$matches[1]", "(feed|rdf|rss|rss2|atom)/?$": "index.php?&feed=$matches[1]", "embed/?$": "index.php?&embed=true", "page/?([0-9]{1,})/?$": "index.php?&paged=$matches[1]", "comments/feed/(feed|rdf|rss|rss2|atom)/?$": "index.php?&feed=$matches[1]&withcomments=1", "comments/(feed|rdf|rss|rss2|atom)/?$": "index.php?&feed=$matches[1]&withcomments=1", "comments/embed/?$": "index.php?&embed=true", "search/(.+)/feed/(feed|rdf|rss|rss2|atom)/?$": "index.php?s=$matches[1]&feed=$matches[2]", "search/(.+)/(feed|rdf|rss|rss2|atom)/?$": "index.php?s=$matches[1]&feed=$matches[2]", "search/(.+)/embed/?$": "index.php?s=$matches[1]&embed=true", "search/(.+)/page/?([0-9]{1,})/?$": "index.php?s=$matches[1]&paged=$matches[2]", "search/(.+)/?$": "index.php?s=$matches[1]", "author/([^/]+)/feed/(feed|rdf|rss|rss2|atom)/?$": "index.php?author_name=$matches[1]&feed=$matches[2]", "author/([^/]+)/(feed|rdf|rss|rss2|atom)/?$": "index.php?author_name=$matches[1]&feed=$matches[2]", "author/([^/]+)/embed/?$": "index.php?author_name=$matches[1]&embed=true", "author/([^/]+)/page/?([0-9]{1,})/?$": "index.php?author_name=$matches[1]&paged=$matches[2]", "author/([^/]+)/?$": "index.php?author_name=$matches[1]", "([0-9]{4})/([0-9]{1,2})/([0-9]{1,2})/feed/(feed|rdf|rss|rss2|atom)/?$": "index.php?year=$matches[1]&monthnum=$matches[2]&day=$matches[3]&feed=$matches[4]", "([0-9]{4})/([0-9]{1,2})/([0-9]{1,2})/(feed|rdf|rss|rss2|atom)/?$": "index.php?year=$matches[1]&monthnum=$matches[2]&day=$matches[3]&feed=$matches[4]", "([0-9]{4})/([0-9]{1,2})/([0-9]{1,2})/embed/?$": "index.php?year=$matches[1]&monthnum=$matches[2]&day=$matches[3]&embed=true", "([0-9]{4})/([0-9]{1,2})/([0-9]{1,2})/page/?([0-9]{1,})/?$": "index.php?year=$matches[1]&monthnum=$matches[2]&day=$matches[3]&paged=$matches[4]", "([0-9]{4})/([0-9]{1,2})/([0-9]{1,2})/?$": "index.php?year=$matches[1]&monthnum=$matches[2]&day=$matches[3]", "([0-9]{4})/([0-9]{1,2})/feed/(feed|rdf|rss|rss2|atom)/?$": "index.php?year=$matches[1]&monthnum=$matches[2]&feed=$matches[3]", "([0-9]{4})/([0-9]{1,2})/(feed|rdf|rss|rss2|atom)/?$": "index.php?year=$matches[1]&monthnum=$matches[2]&feed=$matches[3]", "([0-9]{4})/([0-9]{1,2})/embed/?$": "index.php?year=$matches[1]&monthnum=$matches[2]&embed=true", "([0-9]{4})/([0-9]{1,2})/page/?([0-9]{1,})/?$": "index.php?year=$matches[1]&monthnum=$matches[2]&paged=$matches[3]", "([0-9]{4})/([0-9]{1,2})/?$": "index.php?year=$matches[1]&monthnum=$matches[2]", "([0-9]{4})/feed/(feed|rdf|rss|rss2|atom)/?$": "index.php?year=$matches[1]&feed=$matches[2]", "([0-9]{4})/(feed|rdf|rss|rss2|atom)/?$": "index.php?year=$matches[1]&feed=$matches[2]", "([0-9]{4})/embed/?$": "index.php?year=$matches[1]&embed=true", "([0-9]{4})/page/?([0-9]{1,})/?$": "index.php?year=$matches[1]&paged=$matches[2]", "([0-9]{4})/?$": "index.php?year=$matches[1]", ".?.+?/attachment/([^/]+)/?$": "index.php?attachment=$matches[1]", ".?.+?/attachment/([^/]+)/trackback/?$": "index.php?attachment=$matches[1]&tb=1", ".?.+?/attachment/([^/]+)/feed/(feed|rdf|rss|rss2|atom)/?$": "index.php?attachment=$matches[1]&feed=$matches[2]", ".?.+?/attachment/([^/]+)/(feed|rdf|rss|rss2|atom)/?$": "index.php?attachment=$matches[1]&feed=$matches[2]", ".?.+?/attachment/([^/]+)/comment-page-([0-9]{1,})/?$": "index.php?attachment=$matches[1]&cpage=$matches[2]", ".?.+?/attachment/([^/]+)/embed/?$": "index.php?attachment=$matches[1]&embed=true", "(.?.+?)/embed/?$": "index.php?pagename=$matches[1]&embed=true", "(.?.+?)/trackback/?$": "index.php?pagename=$matches[1]&tb=1", "(.?.+?)/feed/(feed|rdf|rss|rss2|atom)/?$": "index.php?pagename=$matches[1]&feed=$matches[2]", "(.?.+?)/(feed|rdf|rss|rss2|atom)/?$": "index.php?pagename=$matches[1]&feed=$matches[2]", "(.?.+?)/page/?([0-9]{1,})/?$": "index.php?pagename=$matches[1]&paged=$matches[2]", "(.?.+?)/comment-page-([0-9]{1,})/?$": "index.php?pagename=$matches[1]&cpage=$matches[2]", "(.?.+?)(?:/([0-9]+))?/?$": "index.php?pagename=$matches[1]&page=$matches[2]", "[^/]+/attachment/([^/]+)/?$": "index.php?attachment=$matches[1]", "[^/]+/attachment/([^/]+)/trackback/?$": "index.php?attachment=$matches[1]&tb=1", "[^/]+/attachment/([^/]+)/feed/(feed|rdf|rss|rss2|atom)/?$": "index.php?attachment=$matches[1]&feed=$matches[2]", "[^/]+/attachment/([^/]+)/(feed|rdf|rss|rss2|atom)/?$": "index.php?attachment=$matches[1]&feed=$matches[2]", "[^/]+/attachment/([^/]+)/comment-page-([0-9]{1,})/?$": "index.php?attachment=$matches[1]&cpage=$matches[2]", "[^/]+/attachment/([^/]+)/embed/?$": "index.php?attachment=$matches[1]&embed=true", "([^/]+)/embed/?$": "index.php?name=$matches[1]&embed=true", "([^/]+)/trackback/?$": "index.php?name=$matches[1]&tb=1", "([^/]+)/feed/(feed|rdf|rss|rss2|atom)/?$": "index.php?name=$matches[1]&feed=$matches[2]", "([^/]+)/(feed|rdf|rss|rss2|atom)/?$": "index.php?name=$matches[1]&feed=$matches[2]", "([^/]+)/page/?([0-9]{1,})/?$": "index.php?name=$matches[1]&paged=$matches[2]", "([^/]+)/comment-page-([0-9]{1,})/?$": "index.php?name=$matches[1]&cpage=$matches[2]", "([^/]+)(?:/([0-9]+))?/?$": "index.php?name=$matches[1]&page=$matches[2]", "[^/]+/([^/]+)/?$": "index.php?attachment=$matches[1]", "[^/]+/([^/]+)/trackback/?$": "index.php?attachment=$matches[1]&tb=1", "[^/]+/([^/]+)/feed/(feed|rdf|rss|rss2|atom)/?$": "index.php?attachment=$matches[1]&feed=$matches[2]", "[^/]+/([^/]+)/(feed|rdf|rss|rss2|atom)/?$": "index.php?attachment=$matches[1]&feed=$matches[2]", "[^/]+/([^/]+)/comment-page-([0-9]{1,})/?$": "index.php?attachment=$matches[1]&cpage=$matches[2]", "[^/]+/([^/]+)/embed/?$": "index.php?attachment=$matches[1]&embed=true" }

As you can see that’s quite a lot of rewrite rules. We can use the plugin Debug Bar along with the plugin Debug Bar Rewrite Rules to see what rewrites match with an URL.

As you can see, for our URL, we can see that there are three rewrite rules that match.

The first is the rewrite rule for our Product CPT.

The second one is a rewrite rule for pages.

Finally, the third is a rewrite rule for attachments.

What’s important is the order of the rewrites. While the array of rewrite rules might look like an associative array, it also has an order. When matching an URL to the rewrite array, WordPress starts from the top and works all the way down and selects the first rule that matches. When you create a new CPT, WordPress will put the rewrite rules for your CPT before the rules for a page. Because the rewrite rules for our CPT are above the rules for the page, WordPress will always check the CPT rules first.

What’s more, it seems like the rewrite rule for pages is special. If a page matching that rule is not found, the request “falls through” and the next matching rule is tried. It seems like this has something to do with something called “verbose page rules”, which is an option that was added to WordPress in order to resolve cases where pages and posts have overlapping URLs.

This means if the page rules are located above the CPT rules, WordPress will first check for a page and if it’s not found, it will check for the CPT. For some arcane reason, this only works for pages. For other rules, WordPress will only check the first rule that matches and there’s no post in the database with that slug and that post type, it will simply show a 404 error page.

Re-ordering rewrite rules using a filter

I’m not sure if anyone actually knows why WordPress Rewrite API works like this, but if we want to have pages under a permalink rule reserved for a CPT, we can simply move the CPT rules under the page rule, which is (.?.+?)(?:/([0-9]+))?/?$ .

Let’s add the following code to our plugin/theme:

function mycompany_modify_rewrite_rules($rules) { $my_rules = []; foreach ($rules as $pattern => $rewrite) { if (strpos($pattern, 'products/') === 0) { $my_rules[$pattern] = $rewrite; unset($rules[$pattern]); } } $output = []; foreach ($rules as $pattern => $rewrite) { $output[$pattern] = $rewrite; if ($pattern === '(.?.+?)(?:/([0-9]+))?/?$') { foreach ($my_rules as $my_key => $my_rule) { $output[$my_key] = $my_rule; } } } return $output; } add_filter('rewrite_rules_array', 'mycompany_modify_rewrite_rules');

What this code basically does it collects any rules starting with our CPT slug products and removes them from the rewrite rules array. Then it finds the rewrite rule for pages and adds those rules below it.

Now, if we visit the URL https://example.com/products/promotions-and-sales , we should be able to see that page under a permalink normally reserved for products.

If we visit a Product CPT post, we can see that it also functions correctly.

That’s it. We have successfully modified WordPress rewrite rules to allows us to have a page in an URL normally reserved for CPT posts.

I’ve used this code without any issues so far. But modifying WordPress permalink structure is always risky so I can’t give any compatibility guarantees. Keep in mind it’s possible that future WordPress updates may make changes to permalink structure.

Comments