Headless Bitrix + NextJS: архитектура и реализация

Как использовать 1С-Битрикс как headless CMS для React-фронтенда. REST API, аутентификация, каталог товаров и корзина.

Дмитрий Мещеряков
Дмитрий Мещеряков
📅 14 сентября 2026 г.📖 11 мин чтения

Headless-связка Битрикса с Next.js — решение, которое продают как «современный фронтенд к привычному бэкенду». Технически это правда. Практически же вы получаете распределённую систему из двух приложений со своими деплоями, своим кэшем и своей аутентификацией — и почти все сложности будут именно на стыке, а не внутри каждой из частей.

Разберём архитектуру целиком, а в конце — честный ответ на вопрос, кому это подходит, а кому обойдётся дороже пользы.

Зачем Headless-архитектура

Классический Битрикс — это монолит: PHP-шаблоны, jQuery, своя система кэширования. Работает, но:

  • Скорость разработки — React-компоненты пишутся быстрее шаблонов Битрикс
  • UX — SPA-навигация, оптимистичные обновления, мгновенный отклик
  • SEO — SSR в NextJS даёт лучший контроль над метатегами и разметкой
  • Команда — фронтенд-разработчики не обязаны знать PHP
💡 Совет

Headless не означает отказ от Битрикс. Админка, каталог, заказы, интеграции с 1С — всё остаётся. Меняется только слой отображения.

Архитектура решения

text
┌─────────────────┐     REST API      ┌─────────────────┐
│   1С-Битрикс    │ ◄──────────────── │    NextJS       │
│   (Backend)     │                   │   (Frontend)    │
│                 │                   │                 │
│ • Каталог       │  JSON             │ • SSR/ISR       │
│ • Заказы        │ ────────────────► │ • React UI      │
│ • Пользователи  │                   │ • Корзина       │
│ • Админка       │                   │ • Checkout      │
└─────────────────┘                   └─────────────────┘

Настройка REST API в Битрикс

Создание модуля API

php
// local/modules/custom.api/install/index.php
<?php
use Bitrix\Main\ModuleManager;
use Bitrix\Main\Localization\Loc;

class custom_api extends CModule
{
    public $MODULE_ID = 'custom.api';
    public $MODULE_NAME = 'Custom REST API';
    
    public function DoInstall()
    {
        ModuleManager::registerModule($this->MODULE_ID);
        $this->InstallEvents();
    }
    
    public function InstallEvents()
    {
        RegisterModuleDependences(
            'rest',
            'OnRestServiceBuildDescription',
            $this->MODULE_ID,
            '\\Custom\\Api\\Rest',
            'onRestServiceBuildDescription'
        );
    }
}

REST-контроллер каталога

php
// local/modules/custom.api/lib/rest.php
<?php
namespace Custom\Api;

use Bitrix\Main\Loader;
use Bitrix\Iblock\ElementTable;
use Bitrix\Sale\Basket;

class Rest
{
    public static function onRestServiceBuildDescription(): array
    {
        return [
            'custom.api' => [
                'custom.api.catalog.list' => [
                    'callback' => [__CLASS__, 'getCatalogList'],
                    'options' => [],
                ],
                'custom.api.catalog.item' => [
                    'callback' => [__CLASS__, 'getCatalogItem'],
                    'options' => [],
                ],
                'custom.api.cart.get' => [
                    'callback' => [__CLASS__, 'getCart'],
                    'options' => [],
                ],
                'custom.api.cart.add' => [
                    'callback' => [__CLASS__, 'addToCart'],
                    'options' => [],
                ],
            ],
        ];
    }
    
    public static function getCatalogList(array $params): array
    {
        Loader::includeModule('iblock');
        Loader::includeModule('catalog');
        
        $filter = ['IBLOCK_ID' => CATALOG_IBLOCK_ID, 'ACTIVE' => 'Y'];
        $select = ['ID', 'NAME', 'CODE', 'PREVIEW_TEXT', 'PREVIEW_PICTURE'];
        
        // Фильтрация по разделу
        if (!empty($params['section_code'])) {
            $section = \CIBlockSection::GetList(
                [],
                ['IBLOCK_ID' => CATALOG_IBLOCK_ID, 'CODE' => $params['section_code']],
                false,
                ['ID']
            )->Fetch();
            
            if ($section) {
                $filter['SECTION_ID'] = $section['ID'];
                $filter['INCLUDE_SUBSECTIONS'] = 'Y';
            }
        }
        
        // Пагинация
        $page = (int)($params['page'] ?? 1);
        $limit = min((int)($params['limit'] ?? 20), 100);
        $offset = ($page - 1) * $limit;
        
        $items = [];
        $iterator = ElementTable::getList([
            'filter' => $filter,
            'select' => $select,
            'limit' => $limit,
            'offset' => $offset,
            'order' => ['SORT' => 'ASC', 'ID' => 'DESC'],
        ]);
        
        while ($row = $iterator->fetch()) {
            $items[] = self::formatProduct($row);
        }
        
        // Общее количество
        $total = ElementTable::getCount($filter);
        
        return [
            'items' => $items,
            'pagination' => [
                'page' => $page,
                'limit' => $limit,
                'total' => $total,
                'pages' => ceil($total / $limit),
            ],
        ];
    }
    
    private static function formatProduct(array $row): array
    {
        $price = \CPrice::GetBasePrice($row['ID']);
        $image = $row['PREVIEW_PICTURE'] 
            ? \CFile::GetPath($row['PREVIEW_PICTURE']) 
            : null;
        
        return [
            'id' => (int)$row['ID'],
            'name' => $row['NAME'],
            'slug' => $row['CODE'],
            'description' => strip_tags($row['PREVIEW_TEXT']),
            'price' => $price ? (float)$price['PRICE'] : 0,
            'currency' => $price ? $price['CURRENCY'] : 'RUB',
            'image' => $image ? SITE_URL . $image : null,
            'inStock' => self::checkStock($row['ID']),
        ];
    }
    
    private static function checkStock(int $productId): bool
    {
        $product = \CCatalogProduct::GetByID($productId);
        return $product && $product['QUANTITY'] > 0;
    }
}

NextJS: клиент для Bitrix API

API-клиент

typescript
// lib/bitrix-api.ts
const BITRIX_API_URL = process.env.BITRIX_API_URL!;
const BITRIX_WEBHOOK_TOKEN = process.env.BITRIX_WEBHOOK_TOKEN!;

interface BitrixResponse<T> {
  result: T;
  error?: string;
  error_description?: string;
}

class BitrixApiError extends Error {
  constructor(
    public code: string,
    message: string
  ) {
    super(message);
    this.name = 'BitrixApiError';
  }
}

export async function callBitrixApi<T>(
  method: string,
  params: Record<string, unknown> = {}
): Promise<T> {
  const url = `${BITRIX_API_URL}/rest/1/${BITRIX_WEBHOOK_TOKEN}/${method}.json`;
  
  const response = await fetch(url, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
    },
    body: JSON.stringify(params),
    next: { 
      revalidate: 60, // Кэш на 1 минуту
      tags: ['bitrix', method],
    },
  });
  
  if (!response.ok) {
    throw new BitrixApiError(
      'HTTP_ERROR',
      `HTTP ${response.status}: ${response.statusText}`
    );
  }
  
  const data: BitrixResponse<T> = await response.json();
  
  if (data.error) {
    throw new BitrixApiError(data.error, data.error_description || 'Unknown error');
  }
  
  return data.result;
}

// Типизированные методы
export interface Product {
  id: number;
  name: string;
  slug: string;
  description: string;
  price: number;
  currency: string;
  image: string | null;
  inStock: boolean;
}

export interface PaginatedResponse<T> {
  items: T[];
  pagination: {
    page: number;
    limit: number;
    total: number;
    pages: number;
  };
}

export async function getCatalog(params: {
  page?: number;
  limit?: number;
  section_code?: string;
} = {}): Promise<PaginatedResponse<Product>> {
  return callBitrixApi('custom.api.catalog.list', params);
}

export async function getProduct(slug: string): Promise<Product> {
  return callBitrixApi('custom.api.catalog.item', { code: slug });
}

Server Components для каталога

tsx
// app/catalog/page.tsx
import { getCatalog } from '@/lib/bitrix-api';
import { ProductCard } from '@/components/ProductCard';
import { Pagination } from '@/components/Pagination';

interface CatalogPageProps {
  searchParams: Promise<{ page?: string; category?: string }>;
}

export async function generateMetadata({ searchParams }: CatalogPageProps) {
  const params = await searchParams;
  const page = parseInt(params.page || '1', 10);
  
  return {
    title: page > 1 ? `Каталог — Страница ${page}` : 'Каталог товаров',
    description: 'Широкий ассортимент товаров с доставкой по России',
  };
}

export default async function CatalogPage({ searchParams }: CatalogPageProps) {
  const params = await searchParams;
  const page = parseInt(params.page || '1', 10);
  const category = params.category;
  
  const { items, pagination } = await getCatalog({
    page,
    limit: 24,
    section_code: category,
  });
  
  return (
    <main className="container py-8">
      <h1 className="text-3xl font-bold mb-8">Каталог</h1>
      
      {items.length > 0 ? (
        <>
          <div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-4 gap-6">
            {items.map((product) => (
              <ProductCard key={product.id} product={product} />
            ))}
          </div>
          
          <Pagination
            currentPage={pagination.page}
            totalPages={pagination.pages}
            basePath="/catalog"
          />
        </>
      ) : (
        <p className="text-muted-foreground">Товары не найдены</p>
      )}
    </main>
  );
}

Корзина через Server Actions

tsx
// app/actions/cart.ts
'use server';

import { cookies } from 'next/headers';
import { revalidateTag } from 'next/cache';
import { callBitrixApi } from '@/lib/bitrix-api';

export async function addToCart(productId: number, quantity: number = 1) {
  const cookieStore = await cookies();
  const sessionId = cookieStore.get('PHPSESSID')?.value;
  
  if (!sessionId) {
    // Создаём новую сессию в Битрикс
    const session = await callBitrixApi<{ session_id: string }>(
      'custom.api.session.create'
    );
    cookieStore.set('PHPSESSID', session.session_id, {
      httpOnly: true,
      secure: process.env.NODE_ENV === 'production',
      sameSite: 'lax',
    });
  }
  
  const result = await callBitrixApi<{ success: boolean; cart_count: number }>(
    'custom.api.cart.add',
    { product_id: productId, quantity, session_id: sessionId }
  );
  
  revalidateTag('cart');
  
  return result;
}

export async function getCart() {
  const cookieStore = await cookies();
  const sessionId = cookieStore.get('PHPSESSID')?.value;
  
  if (!sessionId) {
    return { items: [], total: 0 };
  }
  
  return callBitrixApi<CartData>('custom.api.cart.get', { session_id: sessionId });
}

export async function updateCartItem(itemId: number, quantity: number) {
  const cookieStore = await cookies();
  const sessionId = cookieStore.get('PHPSESSID')?.value;
  
  const result = await callBitrixApi('custom.api.cart.update', {
    item_id: itemId,
    quantity,
    session_id: sessionId,
  });
  
  revalidateTag('cart');
  return result;
}

Клиентский компонент корзины

tsx
// components/AddToCartButton.tsx
'use client';

import { useState, useTransition } from 'react';
import { addToCart } from '@/app/actions/cart';
import { useCart } from '@/hooks/useCart';

interface AddToCartButtonProps {
  productId: number;
  inStock: boolean;
}

export function AddToCartButton({ productId, inStock }: AddToCartButtonProps) {
  const [isPending, startTransition] = useTransition();
  const [added, setAdded] = useState(false);
  const { refreshCart } = useCart();
  
  const handleClick = () => {
    if (!inStock) return;
    
    startTransition(async () => {
      try {
        await addToCart(productId, 1);
        setAdded(true);
        refreshCart();
        
        setTimeout(() => setAdded(false), 2000);
      } catch (error) {
        console.error('Failed to add to cart:', error);
      }
    });
  };
  
  if (!inStock) {
    return (
      <button disabled className="btn btn-secondary opacity-50 cursor-not-allowed">
        Нет в наличии
      </button>
    );
  }
  
  return (
    <button
      onClick={handleClick}
      disabled={isPending}
      className={`btn ${added ? 'btn-success' : 'btn-primary'}`}
    >
      {isPending ? (
        <span className="animate-spin"></span>
      ) : added ? (
        '✓ Добавлено'
      ) : (
        'В корзину'
      )}
    </button>
  );
}

Аутентификация между системами

JWT-токены для пользователей

php
// local/modules/custom.api/lib/auth.php
<?php
namespace Custom\Api;

use Firebase\JWT\JWT;
use Firebase\JWT\Key;

class Auth
{
    private const SECRET_KEY = 'your-secret-key-here';
    private const ALGORITHM = 'HS256';
    private const TOKEN_TTL = 3600 * 24 * 7; // 7 дней
    
    public static function generateToken(int $userId): string
    {
        $payload = [
            'user_id' => $userId,
            'iat' => time(),
            'exp' => time() + self::TOKEN_TTL,
        ];
        
        return JWT::encode($payload, self::SECRET_KEY, self::ALGORITHM);
    }
    
    public static function validateToken(string $token): ?int
    {
        try {
            $decoded = JWT::decode($token, new Key(self::SECRET_KEY, self::ALGORITHM));
            return $decoded->user_id;
        } catch (\Exception $e) {
            return null;
        }
    }
    
    public static function login(string $login, string $password): array
    {
        global $USER;
        
        $result = $USER->Login($login, $password, 'Y');
        
        if ($result === true) {
            $userId = $USER->GetID();
            $token = self::generateToken($userId);
            
            return [
                'success' => true,
                'token' => $token,
                'user' => [
                    'id' => $userId,
                    'email' => $USER->GetEmail(),
                    'name' => $USER->GetFullName(),
                ],
            ];
        }
        
        return [
            'success' => false,
            'error' => is_string($result) ? $result : 'Ошибка авторизации',
        ];
    }
}

NextJS Auth через cookies

typescript
// lib/auth.ts
import { cookies } from 'next/headers';
import { callBitrixApi } from './bitrix-api';

interface User {
  id: number;
  email: string;
  name: string;
}

interface LoginResult {
  success: boolean;
  token?: string;
  user?: User;
  error?: string;
}

export async function login(email: string, password: string): Promise<LoginResult> {
  const result = await callBitrixApi<LoginResult>('custom.api.auth.login', {
    login: email,
    password,
  });
  
  if (result.success && result.token) {
    const cookieStore = await cookies();
    cookieStore.set('auth_token', result.token, {
      httpOnly: true,
      secure: process.env.NODE_ENV === 'production',
      sameSite: 'lax',
      maxAge: 60 * 60 * 24 * 7, // 7 дней
    });
  }
  
  return result;
}

export async function getCurrentUser(): Promise<User | null> {
  const cookieStore = await cookies();
  const token = cookieStore.get('auth_token')?.value;
  
  if (!token) return null;
  
  try {
    return await callBitrixApi<User>('custom.api.auth.me', { token });
  } catch {
    return null;
  }
}

export async function logout() {
  const cookieStore = await cookies();
  cookieStore.delete('auth_token');
}

On-Demand Revalidation

При изменении товара в админке Битрикс — обновляем кэш NextJS:

php
// local/php_interface/init.php
AddEventHandler('iblock', 'OnAfterIBlockElementUpdate', function ($arFields) {
    if ($arFields['IBLOCK_ID'] == CATALOG_IBLOCK_ID) {
        // Вызываем revalidation endpoint NextJS
        $url = NEXTJS_URL . '/api/revalidate';
        $data = [
            'secret' => REVALIDATION_SECRET,
            'tag' => 'product-' . $arFields['ID'],
        ];
        
        $ch = curl_init($url);
        curl_setopt($ch, CURLOPT_POST, true);
        curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));
        curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
        curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
        curl_exec($ch);
        curl_close($ch);
    }
});
typescript
// app/api/revalidate/route.ts
import { revalidateTag } from 'next/cache';
import { NextRequest, NextResponse } from 'next/server';

export async function POST(request: NextRequest) {
  const body = await request.json();
  
  if (body.secret !== process.env.REVALIDATION_SECRET) {
    return NextResponse.json({ error: 'Invalid secret' }, { status: 401 });
  }
  
  if (body.tag) {
    revalidateTag(body.tag);
  }
  
  return NextResponse.json({ revalidated: true, now: Date.now() });
}

Что вы теряете, переходя на headless

Разделу с выгодами полагается симметричный раздел с издержками, иначе решение принимается вслепую.

Штатные компоненты Битрикса перестают работать. Умный фильтр, корзина, оформление заказа, личный кабинет, авторизация, компоненты форм — всё это придётся написать заново на React. Это не «немного работы на старте», это основной объём проекта.

Модули маркетплейса становятся бесполезными. Купленный модуль отдаёт вёрстку в публичную часть Битрикса, которой у вас больше нет. Каждая интеграция теперь требует API-обёртки и клиентской реализации.

Композит и штатное кэширование не применимы. Вместо одного механизма кэширования у вас два — на стороне Битрикса и на стороне Next.js, — и их согласование становится вашей задачей. Именно отсюда растут баги вида «в админке изменили, на сайте старое», которые тяжело воспроизвести.

Отладка усложняется. Ошибка может быть в API, в клиенте, в кэше Next.js, в кэше Битрикса или в рассинхроне между ними. Хороший лог с идентификатором запроса, сквозным через обе системы, перестаёт быть роскошью.

Требуется другая команда. Битрикс-разработчик не закроет фронтенд на React, фронтендер не разберётся в модуле sale. Проект, который раньше вёл один человек, теперь требует двоих — и это постоянная стоимость, а не разовая.

Обновления Битрикса нужно проверять по API. Раньше поломка была видна на сайте, теперь — в JSON, который никто не смотрит, пока не пожалуется клиент.

Кому это подходит

Подходит: проектам с высокими требованиями к скорости и интерфейсу, где фронтенд — конкурентное преимущество; сайтам с несколькими витринами на одном бэкенде; командам, где фронтенд-разработчики уже есть.

Не подходит: типовым магазинам на готовом решении, проектам с одним разработчиком, случаям, где основная задача — «сделать быстрее». Ускорить обычный Битрикс кэшированием, композитом и работой над запросами почти всегда дешевле, чем переписать фронтенд целиком.

⚠️ Важно

Проверочный вопрос перед стартом: какая конкретная задача не решается на текущем стеке? Если ответ формулируется как «хотим на современных технологиях» — это не техническая задача, и headless её не решит, а издержки принесёт все. Если ответ звучит как «нам нужна отдача страницы за 200 мс на мобильных при интерактивном каталоге, и мы упёрлись» — тогда да, разговор предметный.

Частые вопросы

Что даёт headless-архитектура с Битриксом?

Скорость разработки интерфейса на React вместо PHP-шаблонов, SPA-навигацию с мгновенным откликом, полный контроль над метатегами и разметкой через SSR в Next.js и возможность нанимать фронтенд-разработчиков, не знающих PHP. Битрикс при этом никуда не девается: админка, каталог, заказы и интеграция с 1С остаются, меняется только слой отображения.

Что перестаёт работать при переходе на headless?

Все штатные компоненты публичной части: умный фильтр, корзина, оформление заказа, личный кабинет, авторизация, формы — их придётся написать заново на React, и это основной объём проекта, а не немного работы на старте. Купленные модули маркетплейса становятся бесполезными: они отдают вёрстку в публичную часть Битрикса, которой у вас больше нет. Композит и штатное кэширование тоже неприменимы.

Как согласовать кэш Битрикса и кэш Next.js?

Через инвалидацию по тегам: запросы к API помечаются тегами в опции next.tags, а обработчик OnAfterIBlockElementUpdate в Битриксе дёргает эндпоинт ревалидации в Next.js с секретом и именем тега, где вызывается revalidateTag. Без такой связки вы получаете классический баг «в админке изменили, на сайте старое», который тяжело воспроизвести, потому что кэшей теперь два и живут они независимо.

Сколько человек нужно на headless-проект?

Минимум двое: битрикс-разработчик не закроет фронтенд на React, а фронтендер не разберётся в модуле sale. Проект, который раньше вёл один человек, теперь требует двоих, и это постоянная стоимость, а не разовая. Плюс усложняется отладка: ошибка может быть в API, в клиенте, в кэше Next.js, в кэше Битрикса или в рассинхроне между ними, поэтому сквозной идентификатор запроса через обе системы перестаёт быть роскошью.

Кому headless подходит, а кому нет?

Подходит проектам с высокими требованиями к скорости и интерфейсу, где фронтенд — конкурентное преимущество; сайтам с несколькими витринами на одном бэкенде; командам, где фронтенд-разработчики уже есть. Не подходит типовым магазинам на готовом решении, проектам с одним разработчиком и случаям, где основная задача — сделать быстрее: ускорить обычный Битрикс кэшированием, композитом и работой над запросами почти всегда дешевле, чем переписать фронтенд целиком.

Как проверить, нужен ли проекту headless?

Задайте один вопрос: какая конкретная задача не решается на текущем стеке. Если ответ формулируется как «хотим на современных технологиях» — это не техническая задача, и headless её не решит, а издержки принесёт все. Если ответ звучит как «нужна отдача страницы за 200 мс на мобильных при интерактивном каталоге, и мы упёрлись» — тогда разговор предметный.