{"id":8429,"date":"2026-03-31T12:00:45","date_gmt":"2026-03-31T09:00:45","guid":{"rendered":"https:\/\/pixadoro.com\/geo-based-ip-redirect-with-cloudflare-and-wordpress-redirect-to-another-domain-by-country-without-plugins\/"},"modified":"2026-03-31T12:13:06","modified_gmt":"2026-03-31T09:13:06","slug":"geo-based-ip-redirect-with-cloudflare-and-wordpress-redirect-to-another-domain-by-country-without-plugins","status":"publish","type":"post","link":"https:\/\/pixadoro.com\/en\/geo-based-ip-redirect-with-cloudflare-and-wordpress-redirect-to-another-domain-by-country-without-plugins\/","title":{"rendered":"Geo-Based IP Redirect with Cloudflare and WordPress: redirect to another domain by country without plugins"},"content":{"rendered":"\n<p class=\"wp-block-paragraph\">You have a WooCommerce store serving customers from Bulgaria and a second domain that sells in Romania. Bulgarian users see the Bulgarian domain and you want to drive Romanian users to the Romanian site. <\/p>\n\n<p class=\"wp-block-paragraph\">The problem: WordPress needs to understand where each visitor is coming from before it loads anything. Most geo-redirect plugins do just that &#8211; execute PHP logic on every request, kill the page cache and add 200-400ms of latency. <\/p>\n\n<p class=\"wp-block-paragraph\">How does a Romanian user automatically get to a Romanian domain where everything is in the language and currency they expect? We&#8217;ll explore two approaches that work well for this task &#8211; one requires Cloudflare Workers, the other is a simple PHP snippet directly in WordPress. <\/p>\n\n<div class=\"wp-block-rank-math-toc-block\" id=\"rank-math-toc\"><h2>\u0421\u044a\u0434\u044a\u0440\u0436\u0430\u043d\u0438\u0435<\/h2><nav><ol><li><a href=\"#zashto-plaginite-za-geo-redirect-zabavyat-woocommerce\">Why geo-redirect plugins slow down WooCommerce<\/a><\/li><li><a href=\"#variant-1-php-snippet-s-cf-ipcountry-hedar\">Option 1: PHP snippet with CF-IPCountry header<\/a><ol><li><a href=\"#kakvo-proveryava-kodat-predi-prenasochvane\">What the code checks before redirecting<\/a><\/li><li><a href=\"#ogranicheniya-na-php-podhoda\">Limitations of the PHP approach<\/a><\/li><\/ol><\/li><li><a href=\"#variant-2-cloudflare-workers-redirect-na-edge-nivo\">Option 2: Cloudflare Workers &#8211; redirect at edge level<\/a><ol><li><a href=\"#sazdavane-na-worker\">Create a Worker<\/a><\/li><li><a href=\"#route-konfiguratsiya\">Route configuration<\/a><\/li><\/ol><\/li><li><a href=\"#zapazvane-na-potrebitelskiya-izbor-s-cookie\">Saving user choices with a cookie<\/a><\/li><li><a href=\"#seo-hreflang-i-indeksirane-pri-dva-domeyna\">SEO: hreflang and indexing with two domains<\/a><\/li><li><a href=\"#php-snippet-ili-workers-koga-koy-variant\">PHP snippet or Workers: when which option<\/a><\/li><li><a href=\"#testvane-bez-patuvane-do-rumaniya\">Testing without travelling to Romania<\/a><\/li><li><a href=\"#%D1%87%D0%B5%D1%81%D1%82%D0%BE-%D0%B7%D0%B0%D0%B4%D0%B0%D0%B2%D0%B0%D0%BD%D0%B8-%D0%B2%D1%8A%D0%BF%D1%80%D0%BE%D1%81%D0%B8\">Frequently Asked Questions<\/a><ol><li><a href=\"#faq-question-5bb1cde15c1552c0\">Does geo-redirect work without Cloudflare?<\/a><\/li><li><a href=\"#faq-question-59de6d374b8035cd\">302 or 301 redirect by country?<\/a><\/li><li><a href=\"#faq-question-176582c87f30f2c6\">How much does Cloudflare Workers cost for geo-redirect?<\/a><\/li><li><a href=\"#faq-question-89d151cd3cf17c79\">What happens to a Romanian consumer who wants to buy from the Bulgarian shop?<\/a><\/li><li><a href=\"#faq-question-6cc1d0fc310e776c\">Will geo-redirect break the WooCommerce cart?<\/a><\/li><\/ol><\/li><\/ol><\/nav><\/div>\n\n<h2 class=\"wp-block-heading\" id=\"zashto-plaginite-za-geo-redirect-zabavyat-woocommerce\">Why geo-redirect plugins slow down WooCommerce<\/h2>\n\n<p class=\"wp-block-paragraph\">Most WordPress plugins for geo-redirect (IP2Location, GeoTargetingWP, Redirection with geo rules) work at PHP level. With each request, WordPress loads the entire stack &#8211; wp-load.php, wp-settings.php, active plugins, theme functions &#8211; just to check the IP address and decide whether to redirect. <\/p>\n\n<p class=\"wp-block-paragraph\">This behavior makes page caching almost impossible. If Varnish or LiteSpeed Cache returns a cached Bulgarian page to a Romanian user, the result is wrong. Therefore, these plugins usually add a DONOTCACHEPAGE constant or exclude certain URLs from the cache. <a href=\"https:\/\/pixadoro.com\/en\/how-to-improve-ttfb-and-server-response-time-in-wordpress\/\">TTFB jumps from 150ms to 600-800ms on<\/a> each first load because each request goes through the full PHP stack. For a shop that relies on <a href=\"https:\/\/pixadoro.com\/en\/core-web-vitals-important-metrics-for-every-website\/\">good Core Web Vitals results, this<\/a> is unacceptable.   <\/p>\n\n<p class=\"wp-block-paragraph\">The two approaches below avoid these plugins entirely.<\/p>\n\n<h2 class=\"wp-block-heading\" id=\"variant-1-php-snippet-s-cf-ipcountry-hedar\">Option 1: PHP snippet with CF-IPCountry header<\/h2>\n\n<p class=\"wp-block-paragraph\">This is the simplest workable option. Cloudflare automatically adds the CF-IPCountry header to every request that passes through the network. The only condition &#8211; the domain must be behind the Cloudflare proxy (the orange cloud in DNS settings). <a href=\"https:\/\/pixadoro.com\/en\/cloudflare-a-beginners-guide\/\">The basic Cloudflare configuration takes<\/a> 10 minutes.  <\/p>\n\n<p class=\"wp-block-paragraph\">The PHP code is placed in the functions.php of the child theme or in the Code Snippets plugin of the Bulgarian domain:<\/p>\n\n<pre class=\"wp-block-code\"><code class=\"\">add_action('template_redirect', function() {\n    if (is_admin() || wp_doing_ajax() || wp_doing_cron()) {\n        return;\n    }\n\n    if (defined('REST_REQUEST') &amp;&amp; REST_REQUEST) {\n        return;\n    }\n\n    $country = strtoupper(\n        sanitize_text_field($_SERVER['HTTP_CF_IPCOUNTRY'] ?? '')\n    );\n\n    if ($country === 'RO') {\n        wp_redirect('https:\/\/example.ro', 302);\n        exit;\n    }\n}, 1);<\/code><\/pre>\n\n<p class=\"wp-block-paragraph\">The priority <code>1<\/code> in add_action is essential. WordPress executes the template_redirect hooks in order of priority &#8211; a value of 1 means the redirect happens before the theme, before WooCommerce (priority 10+), before the analytics plugins. Less PHP code is executed, faster the redirect comes.  <\/p>\n\n<h3 class=\"wp-block-heading\" id=\"kakvo-proveryava-kodat-predi-prenasochvane\">What the code checks before redirecting<\/h3>\n\n<p class=\"wp-block-paragraph\">is_admin() stops execution for \/wp-admin\/ requests. wp_doing_ajax() prevents AJAX calls from the WooCommerce cart and checkout forms. wp_doing_cron() disables WP-Cron tasks. The REST_REQUEST constant protects REST API endpoints that may use other plugins or mobile apps.   <\/p>\n\n<p class=\"wp-block-paragraph\">Without these checks, redirect can break the admin panel when accessed from a Romanian IP, break AJAX adds to the cart, or block API integrations.<\/p>\n\n<h3 class=\"wp-block-heading\" id=\"ogranicheniya-na-php-podhoda\">Limitations of the PHP approach<\/h3>\n\n<p class=\"wp-block-paragraph\">The PHP snippet runs WordPress partially before the redirect. template_redirect is triggered after wp-load.php and wp-settings.php, which means 50-150ms PHP overhead even on the fastest hosting. For most sites this is perfectly acceptable &#8211; the user sees the redirect in under 200ms.  <\/p>\n\n<p class=\"wp-block-paragraph\">The more serious problem is caching. If the server uses full-page caching (Varnish, LiteSpeed, nginx FastCGI), the cached page is served before PHP is triggered. A Romanian user gets the cached Bulgarian HTML instead of the redirect. The solution is to add a Cache-Vary header by CF-IPCountry or exclude redirect pages from the cache.   <\/p>\n\n<pre class=\"wp-block-code\"><code class=\"\">add_action('send_headers', function() {\n    if (!is_admin()) {\n        header('Vary: CF-IPCountry', false);\n    }\n});<\/code><\/pre>\n\n<p class=\"wp-block-paragraph\">This Vary header tells the cache layer that the response depends on the state of the user. LiteSpeed and Varnish will keep separate cache copies for BG and RO traffic. The downside: the cache gets bigger and less efficient because each page is stored in multiple versions.  <\/p>\n\n<h2 class=\"wp-block-heading\" id=\"variant-2-cloudflare-workers-redirect-na-edge-nivo\">Option 2: Cloudflare Workers &#8211; redirect at edge level<\/h2>\n\n<p class=\"wp-block-paragraph\">Cloudflare Workers execute JavaScript on the edge server closest to the user before the request reaches WordPress. There is no PHP overhead. No conflict with page cache. Redirect happens in under 5ms.   <\/p>\n\n<p class=\"wp-block-paragraph\">Workers&#8217; free plan includes 100,000 requests per day &#8211; enough for most small and medium-sized stores.<\/p>\n\n<h3 class=\"wp-block-heading\" id=\"sazdavane-na-worker\">Create a Worker<\/h3>\n\n<p class=\"wp-block-paragraph\">In the Cloudflare Dashboard, Workers &amp; Pages section, click Create Application and then Create Worker. Give a descriptive name &#8211; for example geo-redirect-ro. Cloudflare generates a preview URL, but for production the Worker will connect to the route of the Bulgarian domain.  <\/p>\n\n<pre class=\"wp-block-code\"><code class=\"\">export default {\n  async fetch(request) {\n    const url = new URL(request.url);\n    const country = request.headers.get('CF-IPCountry') || 'XX';\n    const path = url.pathname;\n\n    \/\/ Skip assets, admin, API, AJAX\n    if (\n      path.startsWith('\/wp-admin') ||\n      path.startsWith('\/wp-json') ||\n      path.startsWith('\/wp-content') ||\n      path.match(\/.(js|css|png|jpg|webp|svg|woff2?)$\/) ||\n      url.searchParams.has('wc-ajax')\n    ) {\n      return fetch(request);\n    }\n\n    \/\/ Romanian visitors -&gt; Romanian domain\n    if (country === 'RO') {\n      return Response.redirect('https:\/\/example.ro' + path, 302);\n    }\n\n    return fetch(request);\n  }\n};<\/code><\/pre>\n\n<p class=\"wp-block-paragraph\">The 301 would be cached by the browser and a user traveling between countries would see the wrong domain with no way to correct themselves. <\/p>\n\n<h3 class=\"wp-block-heading\" id=\"route-konfiguratsiya\">Route configuration<\/h3>\n\n<p class=\"wp-block-paragraph\">After deploy, in the Triggers section add the route: <code>example.bg\/*<\/code>. The worker will be triggered for all requests to the Bulgarian domain. The wc-ajax check in the code prevents the WooCommerce AJAX cart from redirecting mid-checkout.  <\/p>\n\n<p class=\"wp-block-paragraph\">For WooCommerce webhooks and API integrations with ERP or accounting software, add additional exceptions. Webhook URLs usually go through \/wp-json\/wc\/v3\/, which is already covered, but custom endpoints may require a separate check. <\/p>\n\n<h2 class=\"wp-block-heading\" id=\"zapazvane-na-potrebitelskiya-izbor-s-cookie\">Saving user choices with a cookie<\/h2>\n\n<p class=\"wp-block-paragraph\">Automatic redirection creates a problem: a Romanian user who wants to order from the Bulgarian store (lower prices, different product catalog) cannot stay on the .bg domain. IP-based geolocation is not 100% accurate &#8211; VPN users, corporate networks and mobile operators routing through another country will get the wrong redirect.<\/p>\n\n<p class=\"wp-block-paragraph\">The cookie override mechanism is mandatory.<\/p>\n\n<p class=\"wp-block-paragraph\">For the PHP version, add a check at the beginning of the snippet:<\/p>\n\n<pre class=\"wp-block-code\"><code class=\"\">add_action('template_redirect', function() {\n    if (isset($_COOKIE['skip_geo_redirect'])) {\n        return;\n    }\n\n    if (is_admin() || wp_doing_ajax() || wp_doing_cron()) {\n        return;\n    }\n\n    if (defined('REST_REQUEST') &amp;&amp; REST_REQUEST) {\n        return;\n    }\n\n    $country = strtoupper(\n        sanitize_text_field($_SERVER['HTTP_CF_IPCOUNTRY'] ?? '')\n    );\n\n    if ($country === 'RO') {\n        wp_redirect('https:\/\/example.ro', 302);\n        exit;\n    }\n}, 1);<\/code><\/pre>\n\n<p class=\"wp-block-paragraph\">On the frontend, language switcher or banner &#8220;Want to stay on the Bulgarian site?&#8221; sets skip_geo_redirect cookie with JavaScript:<\/p>\n\n<pre class=\"wp-block-code\"><code class=\"\">document.cookie = 'skip_geo_redirect=1; max-age=2592000; path=\/; SameSite=Lax';<\/code><\/pre>\n\n<p class=\"wp-block-paragraph\">30 days (2592000 seconds) is a reasonable balance &#8211; long enough not to be annoying, short enough not to be permanent.<\/p>\n\n<p class=\"wp-block-paragraph\">For the Workers variant, add a cookie check before the redirect logic:<\/p>\n\n<pre class=\"wp-block-code\"><code class=\"\">const cookies = request.headers.get('Cookie') || '';\nif (cookies.includes('skip_geo_redirect=1')) {\n  return fetch(request);\n}<\/code><\/pre>\n\n<h2 class=\"wp-block-heading\" id=\"seo-hreflang-i-indeksirane-pri-dva-domeyna\">SEO: hreflang and indexing with two domains<\/h2>\n\n<p class=\"wp-block-paragraph\">Google should know that .bg and .ro domains are language variants of a business, not duplicate content. In the head section of each page of both domains:<\/p>\n\n<p class=\"wp-block-paragraph\">Googlebot usually identifies itself with CF-IPCountry:US. If neither the PHP snippet nor the Worker redirects US traffic (and it shouldn&#8217;t), Google will index the Bulgarian domain normally. The Romanian domain will be indexed independently by Google Romania.  <\/p>\n\n<p class=\"wp-block-paragraph\">A 302 redirect is the correct code for a geo-based redirect. A 301 tells Google &#8220;this page has moved forever&#8221;, which is not true &#8211; the page exists on both domains. <\/p>\n\n<h2 class=\"wp-block-heading\" id=\"php-snippet-ili-workers-koga-koy-variant\">PHP snippet or Workers: when which option<\/h2>\n\n<p class=\"wp-block-paragraph\">The PHP option is suitable when the site is already behind Cloudflare, the hosting does not have an aggressive full-page cache and the traffic is moderate &#8211; up to 50 000 visits per month. Maintenance is simple: a snippet in functions.php or the Code Snippets plugin that every WordPress developer understands. <\/p>\n\n<p class=\"wp-block-paragraph\">Workers option is better for shops with high traffic, aggressive caching (Varnish, LiteSpeed Enterprise, Cloudflare APO) and need for zero PHP overhead. The redirect happens before the origin server knows there is a request. <a href=\"https:\/\/pixadoro.com\/en\/wordpress-speed-optimization\/\">For sites where every millisecond counts, edge<\/a> redirect is the way to go. <\/p>\n\n<p class=\"wp-block-paragraph\">Both options work with <a href=\"https:\/\/pixadoro.com\/en\/what-is-webp-and-how-to-speed-up-wordpress-by-reducing-photo-size-by-up-to-35\/\">WebP image optimization and<\/a> other front-end optimizations without conflict. The geo-redirect logic is completely independent of the rendering pipeline. <\/p>\n\n<h2 class=\"wp-block-heading\" id=\"testvane-bez-patuvane-do-rumaniya\">Testing without travelling to Romania<\/h2>\n\n<p class=\"wp-block-paragraph\">Curl with custom header tests the PHP variant directly:<\/p>\n\n<pre class=\"wp-block-code\"><code class=\"\">curl -I -H \"CF-IPCountry: RO\" https:\/\/example.bg\/<\/code><\/pre>\n\n<p class=\"wp-block-paragraph\">The response expected under HTTP 302 is https:\/\/example.ro. If you get 200 and HTML content instead, the page cache serves before PHP. Clear the cache and test again, or add the Vary header from the constraints section. <\/p>\n\n<p class=\"wp-block-paragraph\">VPN from Romania gives a full end-to-end test &#8211; DNS resolution, Cloudflare edge routing, WordPress redirect. Windscribe and ProtonVPN have free Romanian servers. <\/p>\n\n<p class=\"wp-block-paragraph\">For the Workers variant, Cloudflare Dashboard displays real-time logs in the Workers &gt; Logs section. Each redirect is visible with country code, URL and HTTP status. <\/p>\n\n<h2 class=\"wp-block-heading\" id=\"&#x447;&#x435;&#x441;&#x442;&#x43E;-&#x437;&#x430;&#x434;&#x430;&#x432;&#x430;&#x43D;&#x438;-&#x432;&#x44A;&#x43F;&#x440;&#x43E;&#x441;&#x438;\">Frequently Asked Questions<\/h2>\n<div id=\"rank-math-faq\" class=\"rank-math-block\">\n<ol class=\"rank-math-list \">\n<li id=\"faq-question-5bb1cde15c1552c0\" class=\"rank-math-list-item\">\n<h3 class=\"rank-math-question \">Does geo-redirect work without Cloudflare?<\/h3>\n<div class=\"rank-math-answer \">\n\n<p>The PHP snippet relies on the CF-IPCountry header, which only adds Cloudflare. Without Cloudflare, an alternate geo-IP database is required (MaxMind GeoLite2), which adds additional complexity and query to the database on each load. <\/p>\n\n<\/div>\n<\/li>\n<li id=\"faq-question-59de6d374b8035cd\" class=\"rank-math-list-item\">\n<h3 class=\"rank-math-question \">302 or 301 redirect by country?<\/h3>\n<div class=\"rank-math-answer \">\n\n<p>Always 302 (temporary). 301 is cached by the browser and tells Google that the page has moved permanently. With geo-redirect, both URLs are valid &#8211; they just serve different audiences.  <\/p>\n\n<\/div>\n<\/li>\n<li id=\"faq-question-176582c87f30f2c6\" class=\"rank-math-list-item\">\n<h3 class=\"rank-math-question \">How much does Cloudflare Workers cost for geo-redirect?<\/h3>\n<div class=\"rank-math-answer \">\n\n<p>The free plan includes 100,000 requests per day. For most small and medium shops, this is enough. The paid plan is $5\/month for 10 million queries.  <\/p>\n\n<\/div>\n<\/li>\n<li id=\"faq-question-89d151cd3cf17c79\" class=\"rank-math-list-item\">\n<h3 class=\"rank-math-question \">What happens to a Romanian consumer who wants to buy from the Bulgarian shop?<\/h3>\n<div class=\"rank-math-answer \">\n\n<p>Cookie override mechanism allows manual override. Banner or button sets skip_geo_redirect cookie and redirect stops for 30 days. <\/p>\n\n<\/div>\n<\/li>\n<li id=\"faq-question-6cc1d0fc310e776c\" class=\"rank-math-list-item\">\n<h3 class=\"rank-math-question \">Will geo-redirect break the WooCommerce cart?<\/h3>\n<div class=\"rank-math-answer \">\n\n<p>Not if AJAX requests are excluded from the redirect logic. The PHP snippet checks wp_doing_ajax(), and the Workers variant filters out the wc-ajax parameter. <\/p>\n\n<\/div>\n<\/li>\n<\/ol>\n<\/div>","protected":false},"excerpt":{"rendered":"<p>Two approaches to redirect users by country between two WooCommerce domains &#8211; PHP snippet with CF-IPCountry header and Cloudflare Workers at the edge level. Ready code, cookie override and SEO settings for hreflang. <\/p>\n","protected":false},"author":1,"featured_media":8442,"comment_status":"closed","ping_status":"closed","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[41,42],"tags":[],"class_list":["post-8429","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-woocommerce-en","category-wordpress-en"],"_links":{"self":[{"href":"https:\/\/pixadoro.com\/en\/wp-json\/wp\/v2\/posts\/8429","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/pixadoro.com\/en\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/pixadoro.com\/en\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/pixadoro.com\/en\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/pixadoro.com\/en\/wp-json\/wp\/v2\/comments?post=8429"}],"version-history":[{"count":4,"href":"https:\/\/pixadoro.com\/en\/wp-json\/wp\/v2\/posts\/8429\/revisions"}],"predecessor-version":[{"id":8440,"href":"https:\/\/pixadoro.com\/en\/wp-json\/wp\/v2\/posts\/8429\/revisions\/8440"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/pixadoro.com\/en\/wp-json\/wp\/v2\/media\/8442"}],"wp:attachment":[{"href":"https:\/\/pixadoro.com\/en\/wp-json\/wp\/v2\/media?parent=8429"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/pixadoro.com\/en\/wp-json\/wp\/v2\/categories?post=8429"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/pixadoro.com\/en\/wp-json\/wp\/v2\/tags?post=8429"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}