Hide Add to Cart Button WooCommerce: Doing It Properly
Hide add to cart button WooCommerce guides stop at the template hook. Here is how to also close variation forms, block themes, direct URLs and the Store API.
You want the Add to Cart button gone. Maybe the shop is a catalogue and every sale goes through a rep. Maybe one range is wholesale-only and the public should not be able to buy it. Search for hide add to cart button woocommerce and you will find the same two lines of PHP repeated on site after site. They work, as far as they go. They remove a control, and the control is the least important part of the problem.
WooCommerce accepts orders through at least four doors: the template you just edited, the variation form that JavaScript builds on variable products, a plain GET request to /?add-to-cart=123, and the Store API endpoint that block themes and headless front ends post to. Removing a button closes one of them. This article walks all four, shows which fix belongs to which situation, and is explicit about what none of them solve.
The two snippets everyone gives you#
On a classic theme, the loop button and the single product button are each printed by one core function attached to one hook. Unhook them and the markup is genuinely gone from the HTML, not just hidden.
// Classic templates: remove the button from the shop loop
// and from the single product summary.
add_action( 'init', function () {
remove_action( 'woocommerce_after_shop_loop_item', 'woocommerce_template_loop_add_to_cart', 10 );
remove_action( 'woocommerce_single_product_summary', 'woocommerce_template_single_add_to_cart', 30 );
} );If your theme has moved the loop button to a different hook or priority, remove_action will silently do nothing, because the arguments have to match exactly. The blunter version for the loop is to empty the link itself, which works wherever core builds it:
add_filter( 'woocommerce_loop_add_to_cart_link', '__return_empty_string', 20 );What you should not do is reach for CSS. A display: none rule hides the button from a browser that loads your stylesheet and from nothing else. The anchor is still in the HTML, the add-to-cart URL is still in the href, and anyone with dev tools, a reader-mode extension or a scraper is unaffected. The same goes for the “Hide” toggle in a page builder: it usually ships the markup and hides it client-side.
What most hide add to cart button woocommerce guides leave open#
Four gaps, in roughly the order people discover them.
Variable products and the variations form#
On a classic theme, removing woocommerce_template_single_add_to_cart does take the variations form with it, because that one function fires the woocommerce_variable_add_to_cart action that prints the form. The trouble starts when a theme or page builder renders the product summary through its own template call instead of woocommerce_single_product_summary. Your remove_action then has nothing to remove and the form survives.
There is a second layer here. The variations form is driven by a JSON blob, one entry per variation, carrying the price, the availability text and a purchasability flag. Up to the AJAX threshold — thirty variations by default, filterable through woocommerce_ajax_variation_threshold — WooCommerce prints that blob straight into the page; above it, the same data arrives over AJAX instead. Either way the script reads it and enables or disables its own button as attributes are selected. Hiding the form in PHP without touching the data leaves the data reachable. That JSON is also why the price range on variable products leaks so often even when the visible price looks handled.
Block themes and the product blocks#
Block themes split the button into two different things. On the single product template, the classic Add to Cart with Options block (woocommerce/add-to-cart-form) calls woocommerce_template_single_add_to_cart() internally, so the remove_action above still bites — though WooCommerce is rolling out a block-native replacement for it, so confirm on your own version rather than assuming. In a Product Collection, the button is the woocommerce/product-button block, which renders its own markup from the product object and never passes through woocommerce_after_shop_loop_item. The loop snippet does nothing there. You either delete the block from the template in the Site Editor, or you work at the product level, which is the next section.
The direct add-to-cart URL#
WooCommerce has always supported adding to the cart with a query string: /?add-to-cart=123&quantity=5, on any page of the site. It is handled by the form handler on wp_loaded, long before a single template runs. No amount of template editing touches it. Product IDs are not secret either: they appear in body classes on the single product page, in the public Store API product list, and in the JSON of the very variation form you were trying to remove.
# Does the URL still work? Watch the cart, not the status code.
curl -s -c jar.txt -b jar.txt "https://example.com/?add-to-cart=123" -o /dev/null
curl -s -b jar.txt "https://example.com/cart/" | grep -c "cart_item"The Store API cart endpoint#
Block themes, the Cart and Checkout blocks and any headless front end talk to wc/store/v1. Adding an item is a POST to /wp-json/wc/store/v1/cart/add-item with a product ID and a quantity. No login is required. Cart routes do check a nonce header, but that is a cross-site request guard, not an access control: every visitor’s browser is handed a valid one. Treat the endpoint as reachable by anyone. If you removed a button from a template and stopped there, this is a side door standing wide open.
# A bare POST answers with a nonce error, which proves nothing.
# Take a real nonce from the response headers first, then replay it.
curl -s -D - -o /dev/null https://example.com/wp-json/wc/store/v1/cart
curl -s -X POST https://example.com/wp-json/wc/store/v1/cart/add-item \
-H "Content-Type: application/json" \
-H "Nonce: PASTE_THE_NONCE_HERE" \
-d '{"id":123,"quantity":1}'Refusing the action instead of hiding the control#
All four doors converge on one question that WooCommerce asks about every product: is it purchasable? WC_Cart::add_to_cart() checks it and throws “Sorry, this product cannot be purchased.” when the answer is no, and the Store API cart controller runs the same validation before it will accept an item. Answer that question with a filter and you have closed the URL and the endpoint with the same two lines.
add_filter( 'woocommerce_is_purchasable', '__return_false', 99 );
add_filter( 'woocommerce_variation_is_purchasable', '__return_false', 99 );Two useful side effects. The classic single-product template for simple products begins with an early return when the product is not purchasable, so the button disappears from the product page without a remove_action. And add_to_cart_url() stops returning the add-to-cart query string, so anything that builds a link from it points at the product page instead.
One thing it does not do cleanly: in the shop loop, core swaps the button text to “Read more” and links it to the product. That is a sensible default for out-of-stock items and looks odd on a catalogue, so keep the woocommerce_loop_add_to_cart_link filter as well if you want the loop empty. Scoping it to a category or a set of products is the same filter with a condition, remembering that a variation’s own ID is not its parent’s:
add_filter( 'woocommerce_is_purchasable', 'shop_block_wholesale', 99, 2 );
add_filter( 'woocommerce_variation_is_purchasable', 'shop_block_wholesale', 99, 2 );
function shop_block_wholesale( $purchasable, $product ) {
$id = $product->get_parent_id() ? $product->get_parent_id() : $product->get_id();
if ( has_term( 'wholesale', 'product_cat', $id ) ) {
return false;
}
return $purchasable;
}Be deliberate about the blast radius. Every code path that adds this product to a cart now fails, including “Order again” from My Account, bundle plugins and anything that rebuilds a basket for a returning customer. That is usually exactly what you wanted, but test those flows rather than discovering them from a support ticket. The same conditional logic, applied to prices instead of purchasability, is covered in hiding prices for specific products or categories.
Comparing the approaches#
Read the last two columns first. They are what separates hiding the control from refusing the sale, and they are the part that most hide add to cart button woocommerce advice never reaches.
| Approach | Removes the button | Stops ?add-to-cart= | Stops Store API add-item |
|---|---|---|---|
CSS display: none | Visually only | No | No |
remove_action on loop and summary | Classic templates | No | No |
woocommerce_loop_add_to_cart_link emptied | Classic loop only | No | No |
| Delete Product Button block in Site Editor | Block loop only | No | No |
| Stock status set to Out of stock | Yes, everywhere | Yes, unless backorders are allowed | Yes, unless backorders are allowed |
woocommerce_is_purchasable false | Single product; loop needs the extra filter | Yes | Yes |
Hiding versus refusing: choose by intent#
The two techniques are not ranked. They mean different things, and using the wrong one produces a shop that contradicts itself.
- The item is temporarily unavailable. Do not filter anything. Set the stock status to Out of stock, with backorders off, and let WooCommerce do its job: it removes the button, prints the availability notice, and reports
OutOfStockin the structured data so search engines stop showing a buyable snippet. Force the button away with a purchasability filter instead and the JSON-LD still saysInStock, because availability is read from stock, not from purchasability. That mismatch is the kind of thing Search Console eventually complains about. - The catalogue is enquiry-only. Refuse the action, and put something in the button’s place. A product page with no price, no button and no next step reads as broken rather than as an invitation. This is where a proper request a quote flow belongs, and it is the job PriceVeil was built for: catalog mode strips add-to-cart from classic and block templates including the variations form, the Store API cart write is rejected server-side, and a quote button takes over the slot.
- Only signed-in customers may buy. Refuse for guests, allow for members, and hide the price alongside the button, since a visible price with no way to act on it invites the email you were trying to avoid. See hiding prices until customers log in.
- You want a browsable brochure site. Refuse everywhere and also turn off the cart and checkout pages, redirects included. The step-by-step version is in WooCommerce catalog mode without a plugin.
What none of this solves#
Being honest about the edges is more useful than a longer snippet.
- Prices stay visible. Purchasability and price display are separate concerns in WooCommerce. Removing the button leaves the price, the price range, the Store API price fields and the JSON-LD offer exactly where they were.
- Product feeds keep exporting. Feed plugins read the product object directly and write price and add-to-cart URLs into a file that Google, Meta or a marketplace fetches on a schedule. Nothing you do on the front end changes that file. Disable the feed or exclude the products at the feed’s own settings.
- Custom theme code goes its own way. A theme that prints its own markup from
$product->add_to_cart_url(), or a page builder product widget with a button baked in, will keep rendering it. Filters cannot reach code that never asks a question. - Express payment buttons are separate. PayPal, Apple Pay and similar gateway plugins inject their own buttons on their own hooks. They are usually gated on purchasability, but confirm rather than assume.
- Admin and authenticated REST are not blocked. A
wc/v3order created with an API key does not go through the cart, so it does not run the purchasability check. That is normally correct behaviour, but know that it is true. - Caches serve the old page. A page cached before you deployed still has the button in it. Purge once, at the host and at the CDN, and re-check in a private window.
What to do next#
Decide first whether you are hiding a control or refusing a sale, because that single choice determines everything above. If the answer is refuse, add the two purchasability filters, keep the loop link filter for tidiness, then verify rather than trust: load a variable product and read the page source for the variation JSON, run the curl commands against the direct URL and the Store API endpoint, and repeat both after the next theme or WooCommerce update. That is the whole test.
The price side of the same problem has the same shape, and it is the side PriceVeil handles: it ships a wp priceveil selftest command that walks every product and exits non-zero when a price surface leaks — price HTML, structured data, the Store API product response and the collection price range. Its documentation lists which surfaces are covered automatically and which ones remain yours to check. Either way, the standard to hold yourself to is the same: the question is not whether the button is on the page, it is whether the product can still be bought.
