# Caching a WooCommerce shop when the price depends on the…

A shop selling into the EU shows a German visitor 19% VAT and a Portuguese visitor 23%, on the same product, at the same URL. Full-page caching assumes…

Source: https://webkodingtheme.com/blog/woocommerce-cache-prices-by-country/

---

Tax and compliance

# Caching a WooCommerce shop when the price depends on the customer’s country

AÖ Arif Özelci · Published 18 Sept 2026 · 9 min read
A shop selling into the EU shows a German visitor 19% VAT and a Portuguese visitor 23%, on the same product, at the same URL. Full-page caching assumes one URL produces one page. The two facts are not actually in conflict — but only if you stop trying to cache the price and cache everything around it instead. Four ways to do that, from a JavaScript fetch to a Varnish ESI block, and the one Googlebot rule that applies to all of them.

## Why the obvious cache serves the wrong price

WooCommerce decides the displayed tax rate from the customer’s location, which it guesses from the IP address (or from the address on the session once there is one). That decision happens in PHP, on every request. A page cache — WP Rocket, LiteSpeed, nginx FastCGI cache, Cloudflare — sits in front of PHP precisely so that most requests never reach it. The first visitor after a purge is from Germany; the cache stores the German page; everyone else sees German prices until it expires.
The usual reaction is to exclude the shop pages from the cache. That works, and it hands back the reason you installed the cache: the home page, the category archives and the product pages are exactly the pages that take the traffic.
The better framing: the price is the only thing on those pages that varies by country. The header, the images, the description, the reviews, the related products — all identical. So the question is not “how do I cache a page that varies” but “how do I keep one small variable piece out of an otherwise static page”.

## 1. Cache the page, fetch the prices

Render every price as a placeholder that carries the product id, and let one request per page fill them in. The full page is cached once for everyone; the prices come from a small endpoint that is cheap enough to run uncached, or that is cached per country in the object cache. // In the theme, wherever a price is printed:
<span class="js-price" data-product="<?php echo esc_attr( $product->get_id() ); ?>"
style="display:inline-block;min-width:5ch"></span>

// The endpoint. Reads the country from the request, not from the cached page.
add_action( &#x27;rest_api_init&#x27;, function () {
register_rest_route( &#x27;shop/v1&#x27;, &#x27;/prices&#x27;, [
&#x27;methods&#x27; => &#x27;GET&#x27;,
&#x27;callback&#x27; => function ( WP_REST_Request $r ) {
$ids = array_map( &#x27;absint&#x27;, explode( &#x27;,&#x27;, $r->get_param( &#x27;ids&#x27; ) ) );
$country = shop_visitor_country(); // cookie → CDN header → WC geolocation
WC()->customer->set_billing_location( $country );
WC()->customer->set_shipping_location( $country );
$out = [];
foreach ( wc_get_products( [ &#x27;include&#x27; => $ids, &#x27;limit&#x27; => -1 ] ) as $p ) {
$out[ $p->get_id() ] = wc_get_price_to_display( $p ); // tax-inclusive per WC settings
}
return $out;
},
&#x27;permission_callback&#x27; => &#x27;__return_true&#x27;,
] );
} ); const nodes = document.querySelectorAll(&#x27;.js-price[data-product]&#x27;);
const ids = [...new Set([...nodes].map(n => n.dataset.product))];
fetch(&#x27;/wp-json/shop/v1/prices?ids=&#x27; + ids.join(&#x27;,&#x27;), { credentials: &#x27;same-origin&#x27; })
.then(r => r.json())
.then(prices => nodes.forEach(n => {
n.textContent = new Intl.NumberFormat(document.documentElement.lang,
{ style: &#x27;currency&#x27;, currency: &#x27;EUR&#x27; }).format(prices[n.dataset.product]);
}));
Two things keep this from feeling cheap. Give the placeholder a minimum width so nothing shifts when the number lands — a price that jumps in after the layout settles is a Cumulative Layout Shift penalty and it looks broken. And send the country in a cookie after the first visit so the endpoint does not have to geolocate on every call; on Cloudflare the CF-IPCountry header already does the lookup for you.
The cost is one extra request per page and a fraction of a second where prices are blank. For most shops that is the right trade: no infrastructure change, works with every cache plugin, and the cache hit rate on shop pages goes to nearly 100%.

## 2. Cache one copy per country

If the extra request bothers you, or the client insists prices must be in the HTML, vary the cache instead of the page. Twenty-seven EU VAT rates means at most twenty-seven copies of each URL. That sounds like a lot and is not: a product page is a few hundred kilobytes, and only the countries that actually send traffic ever get a copy.
The mechanics depend on where the cache lives. Each of these keys the cache on a country value that the edge already knows, so PHP still runs once per country rather than once per visitor.
- Cloudflare Workers / Cache Rules: add the country to the cache key (Cache Rules → “Cache key” → custom, include the header cf-ipcountry). Free-plan Page Rules cannot do this; Workers or a Pro+ Cache Rule can.
- Varnish: in vcl_hash, hash_data(req.http.CF-IPCountry) or the output of the geoip vmod. One line.
- nginx FastCGI cache with the geoip2 module: fastcgi_cache_key "$scheme$request_method$host$request_uri$geoip2_data_country_code";
- LiteSpeed Cache: set a “country” cookie on the first request (from the CDN header or WC’s geolocation) and add it under Cache → Vary. WP Rocket does the same through its “dynamic cookies” filter (rocket_cache_dynamic_cookies).
Whichever you pick, the WooCommerce side is the same: make sure WC’s tax location is set from that same country value early in the request (the woocommerce_customer_default_location filter, or by setting the customer location on init from the cookie), so the cached copy for “PT” really was rendered with Portuguese tax.

## 3. ESI: one page, one hole

Edge Side Includes are the version of option 1 that happens on the server instead of in the browser. The page is cached once, with an <esi:include> tag where each price goes; the cache server fetches the fragment — cached per country — and stitches it in before the response leaves. No JavaScript, no blank prices, no layout shift, and one copy of the page. <esi:include src="/esi/price/123" />
LiteSpeed Enterprise and the LiteSpeed Cache plugin support ESI natively (it is how they keep the cart count fresh on cached pages). Varnish supports it with a couple of VCL lines. Cloudflare and most shared hosts do not. If you control the server this is the cleanest answer; if you do not, it is not available to you and option 1 or 2 is where you land.

## 4. Show net prices and tax the cart — B2B only

The fourth option is to sidestep the whole thing: display prices excluding VAT everywhere, and let WooCommerce add tax in the cart once it knows the address. The page is then identical for everyone and caches trivially.
It is only legal for a business-to-business shop. Consumer price indication rules in the EU require the price shown to be the total the consumer will pay, VAT included. A B2C shop that shows net prices and adds 23% at checkout is not just an abandonment problem; it is a compliance problem. If your client sells to consumers, this option does not exist.

## The rule that applies to all four: Googlebot is in the United States

Whatever you do with the cache, the crawler mostly arrives from California, and it will never send you a cookie it did not get from you first. So there has to be a sane default: a country that a visitor with no signal gets, the same one every time. Pick it (usually the shop’s base country), make sure that variant renders complete prices, and never geo-redirect — a redirect to a country path or subdomain based on IP is the single most reliable way to get the wrong version of your shop indexed.
With option 1, Googlebot sees the placeholders and, because it renders JavaScript, usually the fetched prices too — but Product structured data should not rely on that. Put the default-country price in the JSON-LD on the server side, where it is stable, and let the visible price vary.
And keep one canonical per product. Twenty-seven cache copies of a URL are still one URL; the moment they become twenty-seven URLs, you have a hreflang project instead of a caching one.

## Which one to build

- Shared hosting, or a cache plugin you do not want to fight: option 1 (fetch the prices). One afternoon of work.
- Behind Cloudflare with Workers, or your own nginx/Varnish: option 2 (vary by country). No JavaScript involved, and the price is in the HTML.
- LiteSpeed Enterprise or Varnish and you want it perfect: option 3 (ESI).
- B2B only: option 4, and you can stop reading.
The one thing not to do is exclude the shop from the cache and call it done. The pages that vary by a single number are the pages people actually visit.
Building a WooCommerce store for the EU? WebKoding makes self-hosted WooCommerce plugins — including SplitVAT for products that carry more than one VAT rate — bought once, no subscriptions. Browse the plugins →

AÖ
Arif Özelci Builds the WebKoding plugins. Answers support himself.
Share on X LinkedIn Copy link
In this article Why the obvious cache serves the wrong price 1. Cache the page, fetch the prices 2. Cache one copy per country 3. ESI: one page, one hole 4. Show net prices and tax the cart — B2B only The rule that applies to all four: Googlebot is in the United States Which one to build
Related A product configurator is a pricing problem, not a UI problem UAE e-invoicing is coming to your WooCommerce shop. What that means in practice.
