BitrixNginxPHPSEO

Multi-Region Architecture for a Bitrix E-commerce Store: 27 Cities on Subdomains

Regional subdomains for 27 cities: an explicit domain list instead of wildcard routing, SEO duplicate protection, returning users to their city, and isolating a CRM portal on the same server.

Result27 cities, 0 duplicates
Aleksandr Butakov
Aleksandr Butakov
4 min read

Problem

A 1C-Bitrix project with the Aspro: Max template. Timeweb Cloud hosting, the classic Nginx + Apache pair. The task sounded simple: regional site versions for 27 Russian cities on subdomains, with sane SEO and an unbroken user journey.

Aspro ships regional support out of the box, sort of. I enabled it, rolled out the subdomains and went to check. Then the list of problems started.

Requests to the new subdomains never reached the site at all. The default Nginx block caught them and served the BitrixVM stub page. The city-picker popup generated links to the main domain instead of subdomains. It took me a while to figure out why: the Aspro component expects iblock properties filled in a very specific way, and none of it is properly documented.

The worst part surfaced later. The blog and company news opened on all 27 subdomains. Do the math: every section multiplied by 27 cities. Hundreds of thousands of duplicate pages, with search bots burning their crawl budget on copies of the same article. Yandex does not forgive that.

Then the user journey. A visitor browses their city's catalog, clicks through to read a blog post on the main domain, then heads back to the catalog. And lands in the main domain's catalog, city forgotten. On top of that, links to shared sections inside product cards pointed at subdomains and collected pointless 301 redirects, leaking link equity for nothing.

CRM went down while I was still setting up regionality for the store. The same server also runs a Bitrix24 corporate portal on crm.example.com. The moment I applied the base subdomain infrastructure settings, the portal stopped opening deals: clicking a lead froze the interface into a white screen, and the console filled with Vue router errors: AbortError: Transition was skipped. The portal's core tried to load the store's menu files and crashed with a Fatal Error.

Solution

Infrastructure first. I set up wildcard DNS (*.example.com) and wrote an Nginx regex that matched any example.com subdomain. Fast to write, and exactly what killed the CRM: the greedy regex also caught crm.example.com and routed it into the store's DOCUMENT_ROOT. I did not want to debug that in production, so I dropped the regex and listed all 27 cities explicitly, with the CRM subdomain deliberately left out.

While proxying to Apache I also passed through the original $host. Without that header the Bitrix core has no idea which subdomain it is on. One line, and nothing works without it. Duplicates got closed in the same place: any request to /blog/ or /company/news/ on a subdomain gets a 301 to the main domain. PHP and the database are not even involved, the redirect fires instantly.

Nginx
server {
    server_name example.com www.example.com ekb.example.com spb.example.com krasnodar.example.com; # and 24 more cities

    location ~ ^/(blog|company/news)/ {
        return 301 https://example.com$request_uri;
    }

    location / {
        proxy_pass http://127.0.0.1:8080;
        proxy_set_header Host $host;
    }
}

Now the PHP part. All the logic lives in one class with two event handlers. I cleaned it up for the article: the domain became example.com, and wherever you need to plug in your own values there's a TODO comment. The first method brings the visitor back to their city. If someone ends up on the main domain inside a regular section like the catalog, the script reads the region ID from the current_region cookie (set by the Aspro component), pulls the region's domain from the iblock and redirects there with all GET parameters intact. Filters, pagination and utm tags survive the trip. The second method is the SEO patch: on OnEndBufferContent regular expressions scan the final HTML right before it goes to the browser and replace regional links to the blog and news with absolute main-domain URLs. Yandex and Google bots see clean direct links, with zero internal redirects.

Note the guard blocks at the top of both methods. They stop the scripts from firing in the admin panel, AJAX requests, REST API and CLI. Learned that the hard way: without the guard the handler breaks imports.

The file /local/php_interface/include/regional_handlers.php:

PHP
<?php
/**
 * Event handlers for subdomain routing and SEO optimization
 * on multi-region 1C-Bitrix projects (Aspro: Max template)
 */
class RegionalSeoHandlers
{
    /**
     * Returns the visitor to their regional subdomain when they leave shared sections.
     * Protects the user context from being lost (falling out of the region).
     */
    public static function returnToRegionalDomain()
    {
        // 1. Skip admin panel, REST API, AJAX and CLI so we don't break system processes
        if (defined('ADMIN_SECTION') && ADMIN_SECTION === true) return;
        if (php_sapi_name() === 'cli') return;
        if (isset($_SERVER['HTTP_BX_AJAX']) || isset($_REQUEST['ajax']) || (isset($_SERVER['HTTP_X_REQUESTED_WITH']) && $_SERVER['HTTP_X_REQUESTED_WITH'] == 'XMLHttpRequest')) return;
        if (strpos($_SERVER['REQUEST_URI'], '/bitrix/') === 0 || strpos($_SERVER['REQUEST_URI'], '/rest/') === 0) return;

        $host = str_replace('www.', '', $_SERVER['HTTP_HOST']);
        $uri = $_SERVER['REQUEST_URI'];

        // TODO: set your main domain (no protocol)
        $mainDomain = 'example.com';

        // 2. Excluded sections that physically exist only on the main domain
        // TODO: list your shared section directories separated by |
        $isExcluded = preg_match('#^/(blog|company/news)/#i', $uri);

        // 3. Visitor is on the main domain but entered a regular section (catalog, cart, etc.)
        if ($host === $mainDomain && !$isExcluded) {

            // Read the region ID straight from the user's cookie (set by the Aspro component)
            $regionCookieId = isset($_COOKIE['current_region']) ? (int)$_COOKIE['current_region'] : 0;

            if ($regionCookieId > 0 && \Bitrix\Main\Loader::includeModule('iblock')) {

                // Fetch the region's domain directly from the iblock database
                $dbRegion = \CIBlockElement::GetList(
                    [],
                    ['ID' => $regionCookieId, 'ACTIVE' => 'Y'],
                    false,
                    false,
                    ['ID', 'IBLOCK_ID', 'PROPERTY_MAIN_DOMAIN']
                );

                if ($arRegion = $dbRegion->Fetch()) {
                    $regionDomain = $arRegion['PROPERTY_MAIN_DOMAIN_VALUE'];
                    $regionDomain = trim(str_replace(['http://', 'https://'], '', $regionDomain));

                    // 4. Instant redirect if the domain exists and differs from the main one
                    if (!empty($regionDomain) && $regionDomain !== $mainDomain) {
                        // Keep GET parameters (filters, pagination, utm tags)
                        $queryString = $_SERVER['QUERY_STRING'] ? '?' . $_SERVER['QUERY_STRING'] : '';
                        $newUrl = 'https://' . $regionDomain . current(explode('?', $uri)) . $queryString;

                        LocalRedirect($newUrl, true, '301 Moved Permanently');
                    }
                }
            }
        }
    }

    /**
     * SEO patch: forcibly replaces relative and wrongly generated
     * regional links with absolute main-domain URLs.
     */
    public static function forceAbsoluteLinksForMainSections(&$content)
    {
        // Skip system and AJAX requests
        if (
            (defined('ADMIN_SECTION') && ADMIN_SECTION === true) ||
            php_sapi_name() === 'cli' ||
            strpos($_SERVER['REQUEST_URI'], '/bitrix/') === 0 ||
            strpos($_SERVER['REQUEST_URI'], '/rest/') === 0 ||
            (isset($_SERVER['HTTP_X_REQUESTED_WITH']) && $_SERVER['HTTP_X_REQUESTED_WITH'] == 'XMLHttpRequest') ||
            (isset($_REQUEST['ajax']) && strtolower((string)$_REQUEST['ajax']) === 'y')
        ) {
            return;
        }

        // TODO: set your main domain (with protocol)
        $mainDomain = 'https://example.com';

        // Rule 1: relative links (e.g. href="/blog/...")
        // TODO: list your shared sections
        $content = preg_replace(
            '/(href=["\'])\/(blog|company\/news)\//i',
            '$1' . $mainDomain . '/$2/',
            $content
        );

        // Rule 2: links where the component already inserted a subdomain (href="https://ekb.example.com/blog/...")
        // TODO: escape the dots in your domain name (e.g. example\.com)
        $content = preg_replace(
            '/(href=["\'])https?:\/\/[a-zA-Z0-9-]+\.example\.com\/(blog|company\/news)\//i',
            '$1' . $mainDomain . '/$2/',
            $content
        );
    }
}

Wiring it up takes a couple of calls in init.php. I try to keep init.php itself empty: in Bitrix projects it turns into a dumping ground anyway. Just a require and handler registration. An isolated class is easier to read, survives core and template updates, and is simple to extend.

I also added a host check right there: if the request is for crm.example.com, the handlers never get registered at all. Nginx already keeps CRM traffic out of the store, but the extra guard in PHP costs nothing. And if someone adds another subdomain to this server later, it won't let the same story repeat.

PHP
<?php
// /local/php_interface/init.php

if (strpos($_SERVER['HTTP_HOST'] ?? '', 'crm.example.com') === false) {
    if (file_exists(__DIR__ . '/include/regional_handlers.php')) {
        require_once(__DIR__ . '/include/regional_handlers.php');

        // Return the visitor to their region when leaving global sections
        AddEventHandler(
            "main",
            "OnPageStart",
            ["RegionalSeoHandlers", "returnToRegionalDomain"]
        );

        // Strip regional links to global sections from the HTML
        AddEventHandler(
            "main",
            "OnEndBufferContent",
            ["RegionalSeoHandlers", "forceAbsoluteLinksForMainSections"]
        );
    }
}

Result

  • 0 content duplicates — bots see clean direct links
  • Part of the load moved from PHP to fast Nginx
  • Users move freely between the global blog and their city's catalog
  • The store and CRM share a server without stepping on each other: Nginx and the PHP core keep them apart
  • Adding city number 28 or 50 takes one iblock element — infrastructure and code pick it up automatically

Was this article helpful?

Need help with something similar?

Website support →