This is WooCommerce-specific: products are a custom post type (product) with a fixed /product/ URL base, and the only thing standing between you and clean example.com/blue-widget URLs is that base plus a slug-collision problem nobody warns you about. To get there you have two levers. The quick one is Settings > Permalinks, where WooCommerce lets you swap /product/ for the shop page name (/shop/) or any custom prefix. The thorough one is code: filter the product permalink so WordPress emits the clean URL, then add a rewrite rule so it can resolve that URL back to the product. The settings page alone cannot fully blank the base without breaking routing, which is the whole reason this article exists.
The honest version up front: a completely base-less product URL (/blue-widget, no prefix at all) is achievable but fragile, because WordPress then cannot tell a product apart from a page, post, or category that happens to share the slug. I will show the code, then show exactly where it bites and how to guard it.
The structure options, ranked
There are realistically four shapes you can give a WooCommerce product URL:
| Structure | Example | Base collision risk | My take |
|---|---|---|---|
| Default base | /product/blue-widget | None | Safe, ugly, what you are escaping |
| Custom single base | /store/blue-widget | None | Cleanest safe option |
| Category in path | /widgets/blue-widget | Low | Good for browse-heavy catalogs |
| No base at all | /blue-widget | High | Looks best, breaks easiest |
The jump in risk happens at the last row. Everything with a base (/product/, /store/, a category segment) gives WordPress a stable token to route on. Strip the base entirely and you are asking the rewrite system to treat a bare slug as a product first, which collides with every page and post on the site.
Option A: change the base in Settings > Permalinks
WooCommerce adds a Product permalinks block to the standard WordPress permalinks screen (Settings > Permalinks). On any non-plain permalink structure, it offers four product-base choices:
- Default maps to
example.com/product/blue-widget. - Shop base uses your shop page slug:
example.com/shop/blue-widget. - Shop base with category nests the category:
example.com/shop/widgets/blue-widget. - Custom base lets you type any prefix, e.g.
example.com/store/blue-widget.
Below that, the Product category base field controls the taxonomy URL, which defaults to /product-category/ (so categories live at example.com/product-category/widgets).
For most stores this UI is all you need. Pick Custom base, type store (or your brand word), save, and you have clean-enough URLs with zero code. Two things WooCommerce's own docs are firm about:
- Do not blank the Custom base field hoping for
/blue-widget. WooCommerce treats an empty custom base as "use the default," not "remove the base." There is no supported UI path to a truly base-less product URL, and trying to fake it by setting the base to/produces the collision problem in the next section. - Keep the product base and the category base distinct. WordPress requires these values to be unique so it can tell a product URL from a category URL. Set the product base to
shopand the category base toshopand routing breaks; pages start resolving to the wrong content or 404.
If you only ever read this far and pick a short custom base, you have done the right thing for 90% of stores.
Option B: do it in code (filter the permalink, add a rewrite rule)
When you want a base-less or otherwise non-standard structure the settings page will not give you, it takes two coordinated pieces, because a WordPress URL has two directions:
- Emit: when WooCommerce prints a link to a product, it should print your clean URL. That is the
post_type_linkfilter. - Resolve: when a browser requests that clean URL, WordPress has to recognize it as a product. That is
add_rewrite_rule().
Get one without the other and you get either pretty links that 404, or working links that still render the old /product/ form everywhere. Here is the pair, in a must-use plugin:
<?php
/**
* Plugin Name: TE Product URLs
* Plugin URI: https://techearl.com/wordpress-custom-product-url-structure
* Description: Builds a custom WooCommerce product URL structure by filtering the
* product permalink and adding a matching rewrite rule.
* Version: 1.0.0
* Author: Ishan Karunaratne
* Author URI: https://techearl.com
* License: GPL-2.0-or-later
* Text Domain: te-product-urls
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
const TE_PRODUCT_URL_BASE = 'store';
// EMIT: rewrite the product permalink to /<base>/<slug>.
function te_product_permalink( $permalink, $post ) {
if ( 'product' !== $post->post_type ) {
return $permalink;
}
return home_url( '/' . TE_PRODUCT_URL_BASE . '/' . $post->post_name . '/' );
}
add_filter( 'post_type_link', 'te_product_permalink', 10, 2 );
// RESOLVE: teach WordPress to route /<base>/<slug> back to the product.
function te_product_rewrite_rule() {
add_rewrite_rule(
'^' . TE_PRODUCT_URL_BASE . '/([^/]+)/?$',
'index.php?post_type=product&name=$matches[1]',
'top'
);
}
add_action( 'init', 'te_product_rewrite_rule' );The post_type_link filter only touches the product post type and leaves every other link untouched. The rewrite rule's regex captures the slug after your base and hands it to the standard post_type=product&name= query that WooCommerce already understands. I register the rule with 'top' so it is evaluated ahead of WordPress's looser default rules; more on why that matters below.
To remove the base entirely, set TE_PRODUCT_URL_BASE to '' and change the regex to ^([^/]+)/?$. That is the line you should hesitate at. Read the next section before you ship it.
The caveat that costs people a weekend: slug collisions and 404s
Here is the failure mode that is missing from most "remove /product/ from WooCommerce" tutorials.
A rewrite rule like ^([^/]+)/?$ -> post_type=product&name=$1 says "any single path segment is a product slug." But /about, /contact, /blog, and every product category slug are also single path segments. With the base-less rule registered at 'top', WordPress tests the product rule first, so a request for /about gets handed to the product query as name=about. If no product named about exists, you get a 404 on your About page. If a product does happen to share that slug, you get the wrong content entirely.
It is worse than a static list of pages, because slugs are created at runtime. The day a client publishes a product named "Contact Kit" with slug contact-kit, nothing breaks. The day they publish a product whose slug collides with an existing page, the page disappears behind the product (or vice versa, depending on rule order). You cannot enumerate the collisions up front.
There are three honest ways to live with this, in order of how much I trust them:
- Keep a base (the real fix). A one-word base like
/store/removes the collision class completely, because/store/blue-widgetcan only ever be a product. You still get a short, clean URL and you keep your sanity. This is what I ship on client sites. - Resolve, do not pre-empt. Register the base-less rule at
'bottom'instead of'top', so WordPress matches real pages, posts, and category archives first and only falls through to the product query when nothing else claims the slug. This protects existing content, but it is fragile in the other direction: a future page created with a slug that an existing product already uses will now shadow the product. Rule order decides who wins; there is no order that is safe in both directions. - Enforce slug uniqueness across products and pages. Hook
wp_unique_post_slug(or validate on save) so a product can never take a slug already used by a page/post, and vice versa. This is the only approach that makes a truly base-less structure correct rather than merely usually-fine, and it is real work: you are reimplementing part of WordPress's namespacing, you have to handle existing collisions at rollout, and you own that code forever.
If after all that you still want no base, do option 3 and accept the maintenance. For everyone else, a short custom base is not a compromise, it is the right answer.
Recommended safe structures
Two structures give you clean URLs without inviting the collision problem:
- Single custom base:
/store/blue-widget. One short segment, no ambiguity, set it in Settings > Permalinks (Custom base) with zero code. This is my default recommendation. - Category in the path:
/widgets/blue-widget. WooCommerce's "Shop base with category" plus a code tweak to drop the shop word gets you here. It reads well for catalogs people browse by category, and the category segment doubles as the disambiguating token, so it carries far less collision risk than a bare slug. The tradeoff is that moving a product between categories changes its URL, so set up redirects if you reorganize.
Both keep a routing anchor in the path, which is the entire point. The base-less /blue-widget looks marginally cleaner and costs you disproportionately more to keep correct.
Flush the rewrite rules (or nothing routes)
WordPress caches its compiled rewrite rules in the database. Adding a rule in code does not take effect until those cached rules are regenerated, so a fresh add_rewrite_rule() will 404 every clean URL until you flush.
Two ways to flush:
- The manual one: go to Settings > Permalinks and click Save Changes without editing anything. This forces a regeneration and is the right move during development.
- The code one: call
flush_rewrite_rules()once, on plugin activation. Never call it on everyinit; it is an expensive write and doing it on every request is a known performance footgun.
register_activation_hook( __FILE__, function () {
te_product_rewrite_rule();
flush_rewrite_rules();
} );For a must-use plugin (which has no activation hook), the manual Settings > Permalinks save is the pragmatic flush. See my deeper notes on the mechanics in the add_rewrite_rule walkthrough.
Verify with curl
Once the rule is flushed, test the resolve direction from the command line rather than trusting the browser cache. A working product URL returns 200:
curl -I https://example.com/store/blue-widgetHTTP/2 200
content-type: text/html; charset=UTF-8
link: <https://example.com/wp-json/wp/v2/product/111>; rel="https://api.w.org/"
Then confirm the two failure modes you care about did not appear:
# An existing page must still be itself, not a product 404:
curl -I https://example.com/about
# The old /product/ URL should redirect or 404, not silently duplicate:
curl -I https://example.com/product/blue-widgetA 200 on /store/blue-widget and a clean 200 on /about means the rule is scoped correctly. A 404 on /about after adding a base-less rule is the collision problem from above announcing itself; put a base back. If you also removed the old base, decide whether /product/blue-widget should 301 to the new URL (good for SEO, avoids duplicate-content) and add that redirect explicitly.
See also
- Adding a Custom Rewrite Rule in WordPress: the foundation under this article, when
add_rewrite_rule()fires, why you must flush, and how query vars map back to a template - Remove the Page Slug from a WordPress URL: the same base-stripping idea applied to pages and the same collision warning that comes with it
- How to Optimize WooCommerce: once the URLs are clean, the bigger store-speed levers that actually move page-load times
Sources
Authoritative references this article was fact-checked against.
- Permalinks: WooCommerce official documentationwoocommerce.com
- add_rewrite_rule(): WordPress Developer Referencedeveloper.wordpress.org
- post_type_link filter: WordPress Developer Referencedeveloper.wordpress.org





