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.
You added the post_type_link filter (which fixes how URLs are written) but either skipped the add_rewrite_rule() call or did not flush the rewrite rules after adding it.
The filter and the rule are two separate halves: the filter is output-side, the rule is input-side. With only the filter, the link renders as /my-doc/ but WordPress has no rule mapping that path to your post type, so it 404s. Add the rule, then re-save Settings, Permalinks to flush.
Yes, both, always. The post_type_link filter controls how WordPress writes the permalink (so menus, canonicals, and sitemaps use the short form). The add_rewrite_rule() call controls how WordPress reads an incoming request and resolves it back to the post.
With only the filter you get pretty links that 404. With only the rule you get working URLs but every generated link still carries the old /docs/ prefix, producing two URLs for one page and a mismatched canonical.
Removing the prefix drops your post type into the same single-segment namespace as Pages, posts, category bases, and date archives. A root rule like ^([^/]+)/?$ matches any one-segment path, so a doc slugged pricing competes with a Page at /pricing/.
The symptoms are a 404 on one of the two, or the wrong template/content served, and which one wins depends on rewrite-rule order. The fix is to resolve the query across post types and only treat a request as a doc when a doc with that slug exists, plus a uniqueness check at save time.
Add a 301 redirect on template_redirect. After WordPress resolves which doc the request is for, compare the current request URL to the clean root URL; if they differ, wp_safe_redirect() to the root form with a 301 and exit.
Because a genuine /my-doc/ request already matches the clean form, the condition is false and no redirect fires, so there is no loop. The 301 carries ranking signals and existing links over to the new URL.
Almost certainly the rewrite-rule flush. WordPress caches compiled rewrite rules in the rewrite_rules option, and a new add_rewrite_rule() has no effect until those are regenerated.
Go to Settings, Permalinks and click Save Changes (you do not need to change anything; saving triggers the flush). Do not call flush_rewrite_rules() on every request to work around this, since it is an expensive option write; flush on activation or by re-saving permalinks.
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
- Build State and City Location URLs in WordPress: the next step up, serving a whole
/state/city/hierarchy from the root with the same rewrite-and-collision discipline - 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





