WooCommerce Catalog Mode Without a Plugin (and Where the Snippets Leak)
Working snippets for WooCommerce catalog mode without a plugin, plus an honest list of where code-only leaks: block themes, variations and the Store API.
You sell to trade customers, or your prices change per contract, or you simply do not want a competitor scraping your catalogue at 3am. So you want the shop to keep working as a catalogue — products, categories, search, images — with no prices and no way to buy. And you would rather not install another plugin to do it.
That is a reasonable thing to want, and for a lot of shops the code below is all you need. What follows is WooCommerce catalog mode without plugin dependencies: three filters and two remove_action calls, in full, ready to paste. After the code comes the part most tutorials skip — the surfaces where a snippet-only approach keeps handing out prices long after the storefront looks clean.
The three things catalog mode actually means#
People say “catalog mode” and mean slightly different things. Before writing code, decide which of these you need, because each one is a separate hook:
- No purchasing. The product cannot be added to the cart, by any route.
- No Add to Cart button. The button is gone from the shop loop and the product page, so nobody is confused.
- No prices. The price is replaced with a label, or removed entirely.
You can have any combination. Some shops keep prices and only remove buying (a showroom that takes phone orders). Some hide prices but keep the cart for logged-in wholesale accounts. WooCommerce catalog mode without plugin code is not one switch, it is three independent hooks, so write down which of the three you want before you paste anything. The wrong combination gives you a product page with a Buy button and no price on it.
All of the code below goes in your child theme’s functions.php or, better, in a small site-specific plugin in wp-content/plugins/. Do not edit a parent theme; the next update erases it.
Step 1: make products unpurchasable#
This is the load-bearing filter. woocommerce_is_purchasable is applied inside WC_Product::is_purchasable(), and WC_Cart::add_to_cart() calls that method before it will accept anything, so returning false here does not just hide a button — it makes the add fail, even if someone crafts the request by hand.
add_filter( 'woocommerce_is_purchasable', '__return_false' );
add_filter( 'woocommerce_variation_is_purchasable', '__return_false' );Two filters, not one. Variations run through their own filter in WC_Product_Variation::is_purchasable(), and forgetting the second one is a common reason a “working” catalog mode still lets people buy a specific size of a variable product.
If you only want catalog mode for part of the shop, take the product argument instead of __return_false:
add_filter( 'woocommerce_is_purchasable', function ( $purchasable, $product ) {
// Catalog mode only for the "trade" category.
if ( has_term( 'trade', 'product_cat', $product->get_id() ) ) {
return false;
}
return $purchasable;
}, 10, 2 );That version is correct for simple products and for variable parents. It is not correct for variations: $product->get_id() on a variation returns the variation’s own ID, and variations do not carry product_cat terms, so has_term() always comes back false. In the variation filter, ask the parent instead with $product->get_parent_id(). Category targeting has more traps than that one, and they are covered in hiding prices for specific products or categories.
Step 2: remove the Add to Cart button from the templates#
Making a product unpurchasable already suppresses the standard button in most classic themes, because the core templates check is_purchasable() before printing anything. But themes override templates constantly, and a theme that prints its own button markup will keep printing it. Remove the actions explicitly:
add_action( 'init', function () {
// Shop loop, related products, up-sells, cross-sells.
remove_action(
'woocommerce_after_shop_loop_item',
'woocommerce_template_loop_add_to_cart',
10
);
// Single product page.
remove_action(
'woocommerce_single_product_summary',
'woocommerce_template_single_add_to_cart',
30
);
} );The priority numbers matter. remove_action() only removes a callback registered at the priority you name, and WooCommerce registers these at 10 and 30 respectively. Get the number wrong and the call silently does nothing — no warning, no error, just a button that is still there. If a theme moved the callback to a different priority, find it with global $wp_filter; print_r( $wp_filter['woocommerce_single_product_summary'] ); on a staging site.
The init wrapper is there so the removal runs after WooCommerce has registered its own template hooks. From a theme’s functions.php you are late enough already; from a site-specific plugin that loads first you are not, and remove_action against a callback that has not been added yet removes nothing.
There is more nuance to this one hook than it looks — themes, block templates, and the variations form each behave differently — and it is unpacked properly in hiding the Add to Cart button in WooCommerce.
Step 3: blank the price#
Catalogue prices in classic templates go through woocommerce_get_price_html. One filter covers the single product page, the shop loop, related products, and the variable product price range:
add_filter( 'woocommerce_get_price_html', function ( $price_html, $product ) {
return '<span class="price-on-request">'
. esc_html__( 'Price on request', 'your-textdomain' )
. '</span>';
}, 100, 2 );Priority 100 rather than 10, so you run after currency switchers, sale-badge plugins and anything else that also filters the price HTML. Returning an empty string instead of a label is fine too, but a label is better for the customer and better for the shop: “Price on request” tells someone to contact you, an empty gap tells them the site is broken.
Note the scope. This filter governs catalogue price display. Cart, mini-cart, checkout and order e-mails format their line items through other filters entirely, woocommerce_cart_item_price among them. With is_purchasable returning false nothing can get into a cart anyway, so those surfaces are moot — unless you are running the hide-prices half without the no-buying half, in which case handle them separately.
Variable products need a second look. The parent’s range goes through the filter above, and so does the price_html string inside the variations JSON, because WC_Product_Variable::get_available_variation() builds that string by calling get_price_html(). What does not go through it are the raw numbers in the same blob: display_price and display_regular_price are floats, printed into the data-product_variations attribute of the form. This is a real leak, not a theoretical one — view source on a variable product, search for display_price, and your numbers are sitting there in plain text. The fix is the woocommerce_available_variation filter, and the details are in hiding the price range on variable products.
Step 4: the settings you should change while you are here#
Two things outside your snippets will undo the work if you leave them alone.
- Cart and Checkout pages. Nobody can add anything, so the pages are harmless, and WooCommerce already emits a noindex directive on cart, checkout and My Account. They do still sit in menus and in whatever your sitemap plugin generates, so tidy those up.
- Product feeds and marketing integrations. If you run CTX Feed, a Google Merchant integration, Facebook for WooCommerce or anything similar, those read the raw price from the database and never touch your display filters. Catalog mode in the theme does nothing to them. Turn them off deliberately.
Also purge your page cache after deploying. A page cached with prices in it stays cached with prices in it, and you will spend twenty minutes convinced your filter is broken.
Where WooCommerce catalog mode without plugin code leaks#
Here is the part most tutorials skip. The hooks above cover the classic PHP rendering path. They do not cover anything else, and modern WooCommerce has a lot of “anything else”.
Block themes render from the Store API, not from your filters#
If your theme is a block theme, or your shop page uses the Products block, or your cart and checkout are the block versions, the price and the button are not printed by woocommerce_template_loop_add_to_cart at all. They are fetched by JavaScript from the Store API at /wp-json/wc/store/v1/products, which serialises each product through its own schema and returns both a price_html string and a structured prices object.
The schema builds price_html from get_price_html(), so your filter does reach that field. The prices object is the problem: regular price, sale price, and minimum and maximum for a variable product, in minor units, taken straight from the product data with no display filter anywhere near them. Open that URL in a browser on your own catalog-mode shop and read the JSON. Every number you thought you removed is there.
The collection endpoint is worse, because it aggregates: /wp-json/wc/store/v1/products/collection-data with a price range request returns the minimum and maximum price across a whole category, which is exactly the number a competitor wants.
The Store API is a cart endpoint, not only a rendering one#
This is the reason to use woocommerce_is_purchasable rather than only removing the button. A POST to /wp-json/wc/store/v1/cart/add-item renders no template, so removing template actions is irrelevant to it. It does check purchasability, so the first snippet holds — but if you skipped it and did only the cosmetic template removal, your “catalog mode” shop takes orders from anyone who reads the API documentation. Test it. Removing a button is not a security boundary.
Unpurchasable does not mean the variations form disappears#
On a variable product, woocommerce_template_single_add_to_cart() fires woocommerce_variable_add_to_cart, which prints the whole variations form: the attribute dropdowns, the reset link, the single-variation container and the JSON blob described earlier. That template does not gate itself on purchasability. Making variations unpurchasable stops the add from succeeding, but you can be left with dropdowns that select a variation and then show nothing, or show a price you meant to hide. Removing the action at priority 30 handles it; relying on purchasability alone does not.
Structured data, the REST API and GraphQL#
WooCommerce builds product JSON-LD and prints it into the page on wp_footer, with an offers.price field taken from the product object rather than from your price HTML filter. SEO plugins that generate their own product schema — Rank Math, Yoast, All in One SEO — do the same thing again in their own output. The wc/v3 REST API returns price, regular_price and sale_price to anyone holding a read key. WPGraphQL with WooGraphQL exposes the same fields to any query it accepts.
You can filter every one of these. It is a few dozen more lines and a list of hooks you have to keep current as WooCommerce and the SEO plugins move. It is honest work, not magic — but it is work, and it is the part that breaks silently on a minor version bump.
What the snippets cover, and what they do not#
| Surface | Covered by the snippets above |
|---|---|
| Classic single product page | Yes |
| Classic shop loop, related, up-sells | Yes |
| Cart add via form POST | Yes (is_purchasable) |
| Variable product price range | Yes |
| Inline variation JSON (display_price) | No — needs its own filter |
| Store API price_html | Yes |
| Store API prices object | No |
| Store API collection price range | No |
| Store API cart write | Rejected, via is_purchasable |
| JSON-LD structured data | No |
| SEO plugin schema | No |
| wc/v3 REST API | No |
| WPGraphQL / WooGraphQL | No |
| Product feed plugins | No — disable them yourself |
| Theme code calling get_price() directly | No — only editing that code fixes it |
Read that table as a decision tool, not a scare list. If you run a classic theme, no SEO schema, no headless front end and no public API keys, the left column is your whole shop and WooCommerce catalog mode without plugin help is genuinely complete. Plenty of shops are in exactly that position. Ship the code and move on.
If the “No” rows describe your site, the choice is between writing and maintaining those filters yourself or using something that already does. PriceVeil covers the same list server-side: storefront, the variations JSON, the Store API product and collection responses, core and SEO-plugin structured data, the wc/v3 REST API and WooGraphQL, all in the free version, and its catalog mode rejects Store API cart writes rather than only hiding the block button. The last two rows of the table it cannot help with, and the documentation says so plainly: feed plugins and direct get_price() calls in theme code are still yours to handle.
Test it before you believe it#
Whichever route you take, verify rather than assume. Five minutes of checking, in a private window so you are logged out:
- View source on a product page. Search for a known price string, then search again for
display_priceand for"price"inside the JSON-LD block near the end of the document. - Open the Store API directly.
/wp-json/wc/store/v1/products?per_page=5and read thepricesobjects. - Try the collection endpoint. Request price range data and see whether the min and max survive.
- Attempt a cart write. POST to
/wp-json/wc/store/v1/cart/add-itemwith a product ID and confirm it is refused. - Check a variable product. Select every attribute combination and watch what appears where the price should be.
That is the same procedure we automate for our own releases, described in detail in how we leak-test every WooCommerce plugin. If you do end up installing PriceVeil, the same walk is one command: wp priceveil selftest checks every product’s price HTML, structured data, Store API response and collection price range, and exits non-zero on the first leak. Doing it by hand works just as well; the point is that you run it again after any theme or WooCommerce update, because the failure mode here is silent. Nothing errors. A price just reappears.
What to do next#
Put the snippets in a site-specific plugin rather than functions.php, so a theme change does not take your catalog mode with it. Purge the cache. Run the five checks above on a logged-out session and write down which surfaces still show a number.
Then decide what the hidden price should lead to. Catalog mode with no next step is a dead end for the customer — they see “Price on request” and have nowhere to click. Most shops that get this right pair it with either a login wall, covered in hiding prices until customers log in, or a quote form, covered in adding a request-a-quote flow. The hiding is the easy half; the asking is what turns a catalogue into a sales channel.
