To serve a custom post type from the site root instead of from under its section slug (so a doc that normally lives at /docs/my-doc/ answers at /my-doc/), you need two halves that have to agree: a filter that renders the short URL, and a rewrite rule that resolves it back to the right post. Here is the whole thing as a must-use plugin:
<?php
/**
* Plugin Name: TE Root Slugs
* Plugin URI: https://techearl.com/wordpress-remove-page-slug-from-url
* Description: Serves the "doc" custom post type from the site root, dropping the /docs/ prefix, with a 301 from old URLs.
* Version: 1.0.0
* Author: Ishan Karunaratne
* Author URI: https://techearl.com
* License: GPL-2.0-or-later
* Text Domain: te-root-slugs
*/
// 1. Render permalinks WITHOUT the /docs/ prefix.
function te_root_permalink( $post_link, $post ) {
if ( 'doc' === $post->post_type && 'publish' === $post->post_status ) {
$post_link = home_url( '/' . $post->post_name . '/' );
}
return $post_link;
}
add_filter( 'post_type_link', 'te_root_permalink', 10, 2 );
// 2. Resolve a root-level slug back to the right doc.
function te_root_rewrite() {
add_rewrite_rule(
'^([^/]+)/?$',
'index.php?post_type=doc&name=$matches[1]',
'top'
);
}
add_action( 'init', 'te_root_rewrite' );That is the pattern. Half one is the post_type_link filter, which rewrites the permalink WordPress hands out so every link, menu item, and canonical tag points at /my-doc/. Half two is add_rewrite_rule() with 'top' priority, which teaches WordPress how to turn an incoming request for /my-doc/ back into the right doc. You need both, and there is a real caveat (collisions) that I will get to, because skipping it is how this pattern breaks production sites.
Why both halves are mandatory
It is tempting to do just one and assume WordPress fills in the other. It does not.
If you add the post_type_link filter but no rewrite rule, your links all render as the clean /my-doc/ URL, but when a visitor clicks one, WordPress has no rule that maps /my-doc/ to your doc post type. It falls through to the default page/post matching, finds nothing, and serves a 404. Pretty URLs that 404 are worse than ugly URLs that work.
If you add the rewrite rule but no filter, requests to /my-doc/ resolve correctly, but every link WordPress generates (get_permalink(), the canonical tag, the sitemap, menu items) still carries the old /docs/my-doc/ form, because the permalink output comes from the post type's registered rewrite slug, not from your rule. You end up with two working URLs for the same content and a canonical pointing at the one you are trying to retire. That is a duplicate-content footgun.
The two halves are mirror images: the filter is the output side (how URLs are written), the rewrite rule is the input side (how URLs are read). They have to describe the same structure or the page is inconsistent.
A note on the registration side: a normal CPT gets its prefix from the rewrite argument in register_post_type(), e.g. 'rewrite' => array( 'slug' => 'docs' ). You are deliberately overriding that structure here rather than changing it, because there is no rewrite slug value that means "no prefix, sit at the root" safely. Leaving the registered slug as docs also gives you a clean fallback URL while you migrate.
Carry the old URLs over with a 301
If /docs/my-doc/ was ever live and indexed, you do not want it to start 404ing the moment you flip to root slugs. Send it to the new URL with a permanent redirect so links and ranking signals carry over:
function te_root_redirect_old() {
if ( is_singular( 'doc' ) ) {
$current = home_url( add_query_arg( array(), $GLOBALS['wp']->request ) );
$clean = home_url( '/' . get_post_field( 'post_name', get_queried_object_id() ) . '/' );
if ( untrailingslashit( $current ) !== untrailingslashit( $clean ) ) {
wp_safe_redirect( $clean, 301 );
exit;
}
}
}
add_action( 'template_redirect', 'te_root_redirect_old' );This fires on template_redirect, after WordPress has resolved which doc the request is for but before it renders. If the resolved URL is not already the clean root form (i.e. someone hit the old /docs/... path, or a non-canonical variant), it 301s to the canonical root URL and stops. A genuine request to /my-doc/ matches the clean form, so the condition is false and nothing happens: no redirect loop.
Use wp_safe_redirect() rather than a bare wp_redirect() so the target is validated against your allowed hosts, and always exit; right after.
The collision caveat (read this before you ship)
Here is the part most tutorials skip. Moving a post type to the root drops it into the same namespace as your Pages, your posts, and any other root-level content. A rule like ^([^/]+)/?$ matches any single-segment path: /about/, /contact/, /2024/, the slug of every top-level Page on the site. That is not a niche edge case, it is the normal shape of a WordPress site.
What goes wrong, concretely:
- You publish a doc with slug
pricing. You also have a Page at/pricing/. Now one of them wins and the other is unreachable, and which one wins depends on rewrite-rule order, which is exactly the thing you have made fragile. - A doc slug collides with a category or tag base, or with the
year/monthdate archive structure, and a perfectly normal URL starts resolving to the wrong template. - Someone adds a new top-level Page months later with a slug that happens to match an existing doc. Nothing errors. The page just quietly serves the wrong content, and you find out from a support ticket.
This is why root slugs are genuinely riskier than the default prefixed structure. The /docs/ prefix exists precisely to give the post type its own namespace so none of this can happen. By removing it you are trading a clean, collision-proof URL for a prettier one, and you take on the job of preventing collisions yourself.
Guarding against it
A few defenses, roughly in order of importance:
Keep the rewrite rule from swallowing real pages. The 'top' priority makes your rule match before WordPress's page rules, which is what makes root slugs resolve at all, but it also means your rule shadows every Page. The safer shape is to only let a request resolve as a doc when a doc with that slug actually exists, and otherwise let WordPress fall through to its normal matching:
function te_root_resolve( $query ) {
if ( ! $query->is_main_query() || is_admin() ) {
return;
}
$name = $query->get( 'name' );
if ( $name && te_root_doc_exists( $name ) ) {
$query->set( 'post_type', array( 'doc', 'page', 'post' ) );
}
}
add_action( 'pre_get_posts', 'te_root_resolve' );
function te_root_doc_exists( $slug ) {
$found = get_posts( array(
'post_type' => 'doc',
'name' => $slug,
'post_status' => 'publish',
'numberposts' => 1,
'fields' => 'ids',
) );
return ! empty( $found );
}Letting the query resolve across doc, page, and post together means a real Page at /pricing/ still wins when there is no doc by that name, instead of being hard-shadowed.
Enforce slug uniqueness at save time. Stop the collision before it exists. On wp_insert_post_data, reject (or suffix) a doc slug that already belongs to a Page, post, or another root-level CPT:
function te_root_unique_slug( $data, $postarr ) {
if ( 'doc' !== $data['post_type'] || empty( $data['post_name'] ) ) {
return $data;
}
$clash = get_page_by_path(
$data['post_name'],
OBJECT,
array( 'page', 'post' )
);
if ( $clash ) {
$data['post_name'] = wp_unique_post_slug(
$data['post_name'] . '-doc',
$postarr['ID'] ?? 0,
$data['post_status'],
'doc',
0
);
}
return $data;
}
add_filter( 'wp_insert_post_data', 'te_root_unique_slug', 10, 2 );Reserve the obvious names. Keep a small denylist of slugs the root pattern must never claim (wp-admin, feed, wp-json, your known page slugs) and bail out of the doc resolution for those. WordPress already reserves some, but your own structural pages are not on that list.
If any of this feels like a lot of defensive plumbing for a cosmetic URL change, that is the honest takeaway: it is. Decide the prettier URL is worth it with eyes open, or keep the prefix.
Flush the rewrite rules
Rewrite rules are cached. A new add_rewrite_rule() does nothing until the rules are regenerated, so after activating the plugin (or editing the rule) you have to flush once:
- The reliable way: go to Settings, Permalinks in wp-admin and click Save Changes. That regenerates the rewrite rules with your new rule included. No settings need to actually change; saving is what triggers the flush.
- Do not call
flush_rewrite_rules()oniniton every request. It is an expensive write to therewrite_rulesoption and there is no reason to run it on a normal page load. Flush on plugin activation/deactivation, or by re-saving permalinks, and otherwise leave it alone.
If /my-doc/ 404s right after you add the rule, an un-flushed rewrite cache is the first thing to check, before you suspect the code.
Verify with curl
Once it is live and flushed, confirm both ends from the command line. The old path should 301 to the root, and the root should answer 200:
# old prefixed URL should permanently redirect to the root
curl -sI https://example.com/docs/my-doc/ | grep -i -E '^(HTTP|location)'
# HTTP/2 301
# location: https://example.com/my-doc/
# the new root URL should serve the doc directly
curl -sI https://example.com/my-doc/ | grep -i '^HTTP'
# HTTP/2 200Then spot-check that you did not break a real Page: curl -sI https://example.com/about/ should still return 200 and render the Page, not your doc template. If it returns the wrong content, your rewrite rule is shadowing pages and you need the pre_get_posts guard above.
On Windows, remember that curl in PowerShell is an alias for Invoke-WebRequest, not the real tool. Call curl.exe -sI ... to get the behavior shown here.
See also
- Add a Custom Rewrite Rule in WordPress: the deeper reference on
add_rewrite_rule(), query vars, rule priority, and the flush lifecycle that this root-slug pattern is built on - Clean Up wp_head in WordPress: once your URLs are the way you want them, trimming the default head output is the other half of a tidy front end
- How to Optimize WooCommerce: when the URL structure is sorted and the real question is why the store is slow, the heavy levers are database-side
Sources
Authoritative references this article was fact-checked against.
- register_post_type(): WordPress Developer Reference (rewrite argument)developer.wordpress.org
- add_rewrite_rule(): WordPress Developer Referencedeveloper.wordpress.org
- post_type_link filter: WordPress Developer Referencedeveloper.wordpress.org





