Вам нужны моделирование контента и редакторская мощь Drupal, но при этом хочется и молниеносный фронтенд на React, переходы как в приложении, и свободу публиковать один и тот же контент сегодня на сайте, а завтра в мобильном приложении. В этом и состоит обещание headless. Это руководство рассказывает историю целиком: как превратить Drupal 11 в безопасный бэкенд с подходом «сначала API» и соединить его с фронтендом на Next.js 16 App Router, который берёт на себя вход, редактирование, медиа и сложные блоки контента, а затем как добавить второй фронтенд, разделяющий ту же систему идентификации.
Оно построено на реальном проекте Drupal 11, который работает локально на DDEV и подготовлен для Pantheon. Каждая команда здесь была выполнена. Каждый код ошибки — тот, что мы видели на самом деле.
А стоит ли вообще переходить на headless?
Перед сборкой — честный вопрос. Headless Drupal решает реальные задачи для одних команд и создаёт новые для других. Выбирайте развязанную архитектуру, когда вам нужна доставка по множеству каналов, современный фронтенд на JS, опыт как в приложении или чистое разделение редактуры и представления. Оставайтесь на традиционном подходе, когда единственный сайт — это и есть весь продукт, а ваша команда живёт в слое тем Drupal.
Правило большого пальца: подбирайте архитектуру под операционную потребность, а не под тренд. Если ваш контент должен достигать более одной «головы» — веб, мобильные устройства, киоск, ИИ-агент, — headless оправдывает себя. Если нет, вы лишь добавляете движущихся частей без всякой отдачи.
Вот этот компромисс с одного взгляда.
| Параметр | Традиционный Drupal | Headless Drupal 11 + Next.js |
|---|---|---|
| Фронтенд | Тема Twig, рендеринг на сервере | React/Next.js, App Router, кеш на edge |
| Охват контента | Один сайт | Веб + приложения + IoT (контент как данные) |
| Потолок производительности | Хороший при кешировании | Как в приложении, тонкий контроль кеша |
| Сложность авторизации | Встроенные сессии | OAuth2 + JWT вы подключаете сами |
| Редакторский предпросмотр | Встроенный | Требует настройки Draft Mode |
| Навыки команды | PHP/Twig | PHP и TypeScript/React |
| Операционная поверхность | Одно приложение в работе | Несколько приложений, CORS, токены, ревалидация |
Если вы всё ещё взвешиваете решение, наш разбор преимуществ использования Drupal в роли headless-CMS рассматривает этот вопрос подробнее.
Стек
| Инструмент | Версия | Роль |
|---|---|---|
| Drupal | 11 (PHP 8.3) | Бэкенд контента и идентификации |
| DDEV | последняя версия | Локальное окружение |
| Next.js | 16.2.9 (App Router, Cache Components) | Фронтенд |
| React | 19.2 (React Compiler) | Интерфейс |
| next-drupal | ^2.0 | Клиент JSON:API |
| Auth.js (next-auth) | ^4.24 | Сессия + вход |
| Tiptap | 3.26 | Редактор форматированного текста |
| Bun | 1.3+ | Среда выполнения + менеджер пакетов |
Защита Drupal 11 как API-бэкенда
Включаем JSON:API
Drupal поставляет JSON:API в ядре — включите его, и вы сразу получаете каждый тип контента, поле и связь в виде RESTful-эндпоинта со встроенными фильтрацией, сортировкой и постраничной разбивкой.
ddev drush en jsonapi serialization -y
ddev drush crДобавьте два contrib-модуля, которые делают его пригодным для продакшена:
ddev composer require drupal/jsonapi_extras:^3.0 drupal/decoupled_router:^2.0
ddev drush en jsonapi_extras decoupled_router -y
ddev drush crdecoupled_router возвращает полностью определённые URL при разрешении путей сущностей — именно это нужно фронтенду на Next.js, чтобы перевести slug в узел.
Настраиваем CORS
Скопируйте файл сервисов и добавьте блок CORS внутри parameters в web/sites/default/services.yml:
cors.config:
enabled: true
allowedHeaders: ['*']
allowedMethods: ['GET', 'POST', 'PATCH', 'DELETE', 'OPTIONS']
allowedOrigins: ['*']
maxAge: 86400
supportsCredentials: falseПримечание для продакшена:
allowedOrigins: ['*']подходит для локальной разработки. В продакшене ограничьте его доменом вашего фронтенда. Открытая политика CORS на авторизованном API — это реальная брешь в безопасности, а не мелочь стиля.
Защищаем с помощью OAuth2
Установите Simple OAuth и вспомогательный модуль для password grant:
ddev composer require drupal/simple_oauth:^6.0 drupal/consumers:^1.0
ddev composer require drupal/simple_oauth_password_grant:^2.1
ddev drush en simple_oauth consumers simple_oauth_password_grant -y
ddev drush crСделайте пути к ключам OAuth зависящими от окружения, чтобы одна и та же конфигурация работала на DDEV и Pantheon. Добавьте это в settings.php:
if (isset($_ENV['PANTHEON_ENVIRONMENT'])) {
$key_path = '/code/private/keys';
} else {
$key_path = '/var/www/html/private/keys';
}
if (file_exists($key_path . '/public.key') && file_exists($key_path . '/private.key')) {
$config['simple_oauth.settings']['public_key'] = $key_path . '/public.key';
$config['simple_oauth.settings']['private_key'] = $key_path . '/private.key';
}Затем создайте OAuth-Consumer (/admin/config/services/consumer/add) с grant-типом Resource Owner Password Credentials. Помните одну вещь с самого начала: consumer'ы — это сущности контента, а не конфигурация: они живут в базе данных и не экспортируются в config/sync, поэтому вы пересоздаёте их в каждом окружении, а не ждёте, что их подтянет импорт конфигурации.
Проверьте эндпоинт токена напрямую:
curl -s -X POST "https://cms.denys-kravchenko.ddev.site/oauth/token" \\
-d "grant_type=password" \\
-d "client_id=nextjs-app" \\
-d "client_secret=your-secret" \\
-d "username=your-user" \\
-d "password=your-pass" \\
-d "scope="Чистый ответ выдаёт вам Bearer-токен с коротким expires_in. Этот токен — ключ к каждой авторизованной записи. Когда перечисляете коллекции вручную, добавьте --globoff к своему curl, чтобы оболочка не пыталась раскрыть скобки в page[limit].
Регистрация пользователей и вход через Google
Самостоятельная регистрация использует REST-эндпоинт Drupal, вызываемый без токена — зарегистрироваться могут только анонимные пользователи, поэтому отправка заголовка Authorization здесь вернёт 403:
curl -s -X POST "https://cms.denys-kravchenko.ddev.site/user/register?_format=json" \\
-H "Content-Type: application/json" \\
-d '{
"name": [{"value": "testuser"}],
"mail": [{"value": "testuser@example.com"}],
"pass": {"value": "TestPass123!"}
}'Одна настройка определяет форму запроса: если «Require email verification» включена, опустите pass — и Drupal вышлет ссылку для входа по почте; если выключена, включите pass — и пользователь сразу становится активным. Отправите пароль при включённой проверке — получите 422, так что согласуйте тело запроса с настройкой, и вызов просто сработает.
Для входа через соцсети Drupal 11 использует Social Auth Google ^4.0 (не 2.x, рассчитанную на более старые версии Drupal). Он регистрирует /user/login/google и проводит обмен OAuth на стороне сервера.
Обратите внимание: интерфейс консоли Google изменился в 2025 году — прежний «экран согласия OAuth» теперь стал Google Auth Platform с разделами Branding, Audience и Clients. Скопируйте URI перенаправления прямо из доступного только для чтения поля Drupal; он должен совпадать символ в символ, включая
https://и завершающий путь, иначе Google ответитredirect_uri_mismatch.
Сборка фронтенда на Next.js (Studio)
Сгенерируйте редакторский фронтенд — назовём его Studio — с помощью Bun, затем добавьте две библиотеки, которые общаются с Drupal:
bun create next-app@latest studio # TypeScript, Biome, React Compiler, Tailwind 4, src/, App Router
cd studio
bun add next-drupal next-authAuth.js использует провайдер учётных данных, который обменивает имя пользователя и пароль на Bearer-токен Drupal по адресу /oauth/token, а затем хранит токен и срок его действия в JWT-сессии:
// src/auth.ts (authorize, сокращено)
const res = await fetch(`${drupalBaseUrl}/oauth/token`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "password",
client_id: drupalClientId,
client_secret: drupalClientSecret,
username,
password,
scope: drupalOAuthScope,
}),
});
if (!res.ok) return null;
const token = await res.json();
return {
id: username,
name: username,
accessToken: token.access_token,
accessTokenExpires: Date.now() + (Number(token.expires_in) || 300) * 1000,
};Чтение идёт по пути. Catch-all маршрут разрешает любой slug в узел Drupal с помощью translatePath, обёрнутый в Cache Components от Next.js, чтобы его можно было ревалидировать по требованию:
// src/lib/drupal-node-by-path.ts
export async function getCachedNodeByPath<T extends DrupalNode>(path: string) {
"use cache";
cacheLife("hours");
cacheTag(DRUPAL_ARTICLES_TAG, drupalPathTag(path));
const translated = await drupal.translatePath(path);
if (!translated?.jsonapi?.resourceName || !translated.entity?.uuid) return null;
cacheTag(drupalNodeTag(translated.entity.uuid));
return drupal.getResource<T>(translated.jsonapi.resourceName, translated.entity.uuid);
}Когда контент меняется в Drupal, модуль Next.js делает POST-запрос на /api/revalidate с общим секретом, и маршрут ревалидирует по тегу. Если вы настраиваете бэкенд, который его питает, наше руководство по оптимизации Drupal 11 для DDEV и Pantheon разбирает масштабирование от локальной разработки до продакшена.
Напоминание о безопасности: DDEV отдаёт HTTPS с локально доверенным сертификатом, и Node по умолчанию ему не доверяет. Укажите Node на CA от mkcert в вашем скрипте
dev(NODE_EXTRA_CA_CERTS="$(mkcert -CAROOT)/rootCA.pem"), а не отключайте проверку TLS — иначе серверныйfetchк Drupal падает с ошибкойUNABLE_TO_VERIFY_LEAF_SIGNATURE.
Нужен эксперт по Drupal?
Echo Flow помогает канадским компаниям с Drupal-разработкой корпоративного уровня.
Настоящий контроль доступа, а не просто «есть токен»
Наличие действительного токена — это не то же самое, что право на запись. Studio проверяет доступ на запись в JSON:API, отправляя POST с пустой статьёй и считывая статус: редактор с правами получает 422 (полезная нагрузка недопустима, но писать вам можно), а пользователь без прав получает 403. Никакой контент при этом не создаётся.
async function probeDrupalArticleCreateAccess(accessToken: string) {
const res = await fetch(`${drupalBaseUrl}/jsonapi/node/article`, {
method: "POST",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/vnd.api+json",
},
body: JSON.stringify({ data: { type: "node--article" } }),
cache: "no-store",
});
return res.status === 422; // 422 = разрешено, 403 = запрещено
}Этот же слой добавляет прямой вход через Google с фронтенда (пользователь никогда не видит URL Drupal), тихое обновление токена через grant refresh_token и предпросмотр в Draft Mode для неопубликованных ревизий.
Редактор в стиле Notion, который остаётся headless
Вот где проект оправдывает себя. Обычная <textarea> превращается в редактор Tiptap с меню по слешу / и сокращениями Markdown. Что важно, никакого нового API сохранения нет — редактор пишет в скрытое поле ввода, которое существующее серверное действие уже читает.
Затем тело контента переходит с Markdown на HTML, чтобы оно могло нести <figure> + <figcaption> + выравнивание + ссылку на медиа Drupal. Изображения загружаются как настоящие, переиспользуемые сущности media--image, а единая общая таблица стилей .rich-content рендерит и редактор, и опубликованную страницу, так что они никогда не разойдутся.
Награда — компонуемый контент: блоки уровня Paragraphs (карточки, выноски, колонки), которые редактор размещает где угодно нажатием /, и всё это управляется одним реестром.
Реестр — это источник истины: одна запись определяет props блока, его общий View (используемый и редактором, и страницей) и необязательный Form внутри редактора:
export type BlockDefinition<P = Record<string, unknown>> = {
type: string;
title: string;
icon: ReactNode;
keywords?: string[];
defaultProps: P;
View: (args: { props: P }) => ReactNode; // общий рендер: редактирование + страница
Form?: (args: { props: P; onChange: (next: P) => void }) => ReactNode;
};Добавьте новый блок — диаграмму, карточку с ценами, колонки — и меню по слешу, предпросмотр в редакторе, сериализация и рендеринг публикации подхватят его все автоматически. Один универсальный узел Tiptap сериализует каждый блок в аккуратный маркер, который рендерер страницы снова сопоставляет с React-компонентом:
<div data-block="card" data-props='{"title":"Pricing","body":"…","cta":"/signup"}'></div>Решение по архитектуре, которое имеет значение: держите структуру блока в приложении Next.js и сохраняйте её как лёгкие метаданные в теле Drupal. Это DRY (один компонент рендерит везде) и оставляет Drupal простым хранилищем. Обращайтесь к Paragraphs Drupal или ссылкам на сущности только тогда, когда блок должен быть общим или запрашиваться на стороне сервера сразу по многим узлам.
Второй фронтенд на одном провайдере идентификации (Reel)
Финальный шаг доказывает, что модель масштабируется: второй фронтенд — видеохаб, который мы назовём Reel, — переиспользует тот же провайдер идентификации Drupal. Назовите его правильно — и дизайн складывается сам собой. Это два клиента OAuth 2.0, разделяющие один сервер авторизации, а не микросервисы и не SSO.
Доставшиеся потом уроки здесь — самые ценные во всей сборке:
- Мы попробовали authorization-code + PKCE (путь, верный по стандарту) и вернулись к password grant. Дрейф портов, доверие к TLS у DDEV, авторизация конфиденциального клиента и отсутствие формы внутри приложения превратились в смерть от тысячи порезов. Вход по password grant в Studio уже работал и мог быть продублирован за один вечер — осознанный, задокументированный обмен чистоты стандарта на единообразие и скорость.
- Столкновение cookie на одном источнике. Два фронтенда на
localhost:3000затирают друг другу cookie сессии, выбрасываяJWT_SESSION_ERROR. Решение — задать пространство имён для cookie у Reel (например,reel.session-token), чтобы они никогда не сталкивались. sub— это целочисленный uid, а не UUID./oauth/userinfoу Simple OAuth возвращает внутренний uid; JSON:API адресует пользователей по UUID. Разрешите его один раз при входе.- Cloudinary для каждого пользователя (приноси свой). Каждый пользователь предоставляет собственный
CLOUDINARY_URL, зашифрованный на стороне приложения с помощью AES-256-GCM ещё до того, как он попадёт в Drupal, — так что Drupal хранит лишь шифротекст, доступный только владельцу благодаря Field Permissions. Передавайте учётные данные в каждом вызове; никогда не меняйте глобальныйcloudinary.config(), что небезопасно при конкурентном доступе.
Интересная работа была на стыках интеграции, а не в самих функциях — пространство имён для cookie, ловушка uid против UUID, сертификат DDEV и сохранение с отзывом токена. Именно эти части стоит запомнить.
Куда двигаться дальше
Теперь у вас есть безопасный API на Drupal 11, редакторский фронтенд с настоящей авторизацией и блочным редактором в стиле Notion, а также второй фронтенд на том же провайдере идентификации. Естественные следующие шаги:
- Ограничьте источники CORS и замените пользователя
administratorиз разработки на узкую рольAPI Client. - Переведите шифрование секретов с единственного ключа окружения на конвертное шифрование через KMS (префикс шифротекста
v1:уже заложен под это). - Добавьте валидацию схемы (например, Zod) для props блоков.
- Переведите редкий общий или запрашиваемый блок на Paragraphs Drupal.
Headless не бесплатен — вы меняете операционную простоту на охват и свободу фронтенда. Но когда вам действительно нужна более чем одна «голова», этот стек даёт быструю, безопасную и удобную для редактора систему, которая относится к вашему контенту как к тому, чем он на самом деле и является: чистым, переносимым данным. Если хотите развить сторону Drupal дальше, см. наше практическое руководство по разработке кастомных сущностей в Drupal 11.2.
Часто задаваемые вопросы
Стоит ли овчинка выделки с headless Drupal 11?
Это зависит от охвата. Если ваш контент должен обслуживать более одного фронтенда — сайт плюс мобильное приложение, киоск или ИИ-агент, — развязанная модель окупает себя гибкостью и скоростью фронтенда. Если единственный сайт — это весь продукт, традиционный Drupal проще и дешевле в эксплуатации.
Обязательно ли использовать OAuth password grant?
Нет, но это прагматичный выбор для доверенного фронтенда первой стороны. Authorization-code + PKCE — это верный по стандарту шаблон, который бесплатно даёт единый вход между приложениями; просто это больше движущихся частей, которые нужно подключить к локальному бэкенду. Password grant быстро даёт работающий вход внутри приложения с понятным путём перехода на PKCE в будущем.
Почему мой запрос регистрации возвращает 403?
Вы отправляете заголовок Authorization. Зарегистрироваться могут только анонимные пользователи, поэтому запрос регистрации должен уходить без прикреплённого Bearer-токена.
Почему Node не доверяет моему HTTPS-бэкенду на DDEV?
DDEV использует локально доверенный сертификат mkcert, который Node не распознаёт, что проявляется как UNABLE_TO_VERIFY_LEAF_SIGNATURE. Укажите Node на корневой CA от mkcert через NODE_EXTRA_CA_CERTS="$(mkcert -CAROOT)/rootCA.pem" в вашем скрипте разработки вместо отключения проверки TLS.
Почему два фронтенда на localhost ломают вход друг друга?
Они используют одни и те же стандартные имена cookie сессии на одном источнике, и ни одно приложение не может расшифровать сессию другого, поэтому вы получаете JWT_SESSION_ERROR. Дайте каждому приложению собственные имена cookie (например, reel.session-token) — и они уживутся без проблем.
Мой OAuth-consumer пропал после импорта конфигурации — почему?
Consumer'ы — это сущности контента, а не конфигурация, поэтому они не входят в config/sync, и импорт конфигурации их не пересоздаст. Добавьте consumer заново в каждом окружении.
Могу ли я добавить в редактор собственные блоки контента?
Да. Добавьте одну запись в реестр блоков — её props, общий View и необязательный Form внутри редактора — и меню по слешу, живой предпросмотр, сериализация и рендеринг публикации подхватят её все автоматически.