Headless-связка Битрикса с Next.js — решение, которое продают как «современный фронтенд к привычному бэкенду». Технически это правда. Практически же вы получаете распределённую систему из двух приложений со своими деплоями, своим кэшем и своей аутентификацией — и почти все сложности будут именно на стыке, а не внутри каждой из частей.
Разберём архитектуру целиком, а в конце — честный ответ на вопрос, кому это подходит, а кому обойдётся дороже пользы.
Зачем Headless-архитектура
Классический Битрикс — это монолит: PHP-шаблоны, jQuery, своя система кэширования. Работает, но:
- Скорость разработки — React-компоненты пишутся быстрее шаблонов Битрикс
- UX — SPA-навигация, оптимистичные обновления, мгновенный отклик
- SEO — SSR в NextJS даёт лучший контроль над метатегами и разметкой
- Команда — фронтенд-разработчики не обязаны знать PHP
Headless не означает отказ от Битрикс. Админка, каталог, заказы, интеграции с 1С — всё остаётся. Меняется только слой отображения.
Архитектура решения
┌─────────────────┐ REST API ┌─────────────────┐
│ 1С-Битрикс │ ◄──────────────── │ NextJS │
│ (Backend) │ │ (Frontend) │
│ │ │ │
│ • Каталог │ JSON │ • SSR/ISR │
│ • Заказы │ ────────────────► │ • React UI │
│ • Пользователи │ │ • Корзина │
│ • Админка │ │ • Checkout │
└─────────────────┘ └─────────────────┘Настройка REST API в Битрикс
Создание модуля API
// 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-контроллер каталога
// 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-клиент
// 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 для каталога
// 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
// 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;
}Клиентский компонент корзины
// 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-токены для пользователей
// 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
// 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:
// 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);
}
});// 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 мс на мобильных при интерактивном каталоге, и мы упёрлись» — тогда разговор предметный.