Вам нужны моделирование контента и редакторская мощь Drupal, но при этом хочется и молниеносный фронтенд на React, переходы как в приложении, и свободу публиковать один и тот же контент сегодня на сайте, а завтра в мобильном приложении. В этом и состоит обещание headless. Это руководство рассказывает историю целиком: как превратить Drupal 11 в безопасный бэкенд с подходом «сначала API» и соединить его с фронтендом на Next.js 16 App Router, который берёт на себя вход, редактирование, медиа и сложные блоки контента, а затем как добавить второй фронтенд, разделяющий ту же систему идентификации.
Оно построено на реальном проекте Drupal 11, который работает локально на DDEV и подготовлен для Pantheon. Каждая команда здесь была выполнена. Каждый код ошибки — тот, что мы видели на самом деле.
Перед сборкой — честный вопрос. 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 поставляет 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 внутри 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 — это реальная брешь в безопасности, а не мелочь стиля.
Установите 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].
Самостоятельная регистрация использует 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.
Сгенерируйте редакторский фронтенд — назовём его 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.
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 для неопубликованных ревизий.
Вот где проект оправдывает себя. Обычная <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, — переиспользует тот же провайдер идентификации Drupal. Назовите его правильно — и дизайн складывается сам собой. Это два клиента OAuth 2.0, разделяющие один сервер авторизации, а не микросервисы и не SSO.
Доставшиеся потом уроки здесь — самые ценные во всей сборке:
localhost:3000 затирают друг другу cookie сессии, выбрасывая JWT_SESSION_ERROR. Решение — задать пространство имён для cookie у Reel (например, reel.session-token), чтобы они никогда не сталкивались.sub — это целочисленный uid, а не UUID. /oauth/userinfo у Simple OAuth возвращает внутренний uid; JSON:API адресует пользователей по UUID. Разрешите его один раз при входе.CLOUDINARY_URL, зашифрованный на стороне приложения с помощью AES-256-GCM ещё до того, как он попадёт в Drupal, — так что Drupal хранит лишь шифротекст, доступный только владельцу благодаря Field Permissions. Передавайте учётные данные в каждом вызове; никогда не меняйте глобальный cloudinary.config(), что небезопасно при конкурентном доступе.Интересная работа была на стыках интеграции, а не в самих функциях — пространство имён для cookie, ловушка uid против UUID, сертификат DDEV и сохранение с отзывом токена. Именно эти части стоит запомнить.
Теперь у вас есть безопасный API на Drupal 11, редакторский фронтенд с настоящей авторизацией и блочным редактором в стиле Notion, а также второй фронтенд на том же провайдере идентификации. Естественные следующие шаги:
administrator из разработки на узкую роль API Client.v1: уже заложен под это).Headless не бесплатен — вы меняете операционную простоту на охват и свободу фронтенда. Но когда вам действительно нужна более чем одна «голова», этот стек даёт быструю, безопасную и удобную для редактора систему, которая относится к вашему контенту как к тому, чем он на самом деле и является: чистым, переносимым данным. Если хотите развить сторону Drupal дальше, см. наше практическое руководство по разработке кастомных сущностей в Drupal 11.2.
Это зависит от охвата. Если ваш контент должен обслуживать более одного фронтенда — сайт плюс мобильное приложение, киоск или ИИ-агент, — развязанная модель окупает себя гибкостью и скоростью фронтенда. Если единственный сайт — это весь продукт, традиционный Drupal проще и дешевле в эксплуатации.
Нет, но это прагматичный выбор для доверенного фронтенда первой стороны. Authorization-code + PKCE — это верный по стандарту шаблон, который бесплатно даёт единый вход между приложениями; просто это больше движущихся частей, которые нужно подключить к локальному бэкенду. Password grant быстро даёт работающий вход внутри приложения с понятным путём перехода на PKCE в будущем.
Вы отправляете заголовок Authorization. Зарегистрироваться могут только анонимные пользователи, поэтому запрос регистрации должен уходить без прикреплённого Bearer-токена.
DDEV использует локально доверенный сертификат mkcert, который Node не распознаёт, что проявляется как UNABLE_TO_VERIFY_LEAF_SIGNATURE. Укажите Node на корневой CA от mkcert через NODE_EXTRA_CA_CERTS="$(mkcert -CAROOT)/rootCA.pem" в вашем скрипте разработки вместо отключения проверки TLS.
Они используют одни и те же стандартные имена cookie сессии на одном источнике, и ни одно приложение не может расшифровать сессию другого, поэтому вы получаете JWT_SESSION_ERROR. Дайте каждому приложению собственные имена cookie (например, reel.session-token) — и они уживутся без проблем.
Consumer'ы — это сущности контента, а не конфигурация, поэтому они не входят в config/sync, и импорт конфигурации их не пересоздаст. Добавьте consumer заново в каждом окружении.
Да. Добавьте одну запись в реестр блоков — её props, общий View и необязательный Form внутри редактора — и меню по слешу, живой предпросмотр, сериализация и рендеринг публикации подхватят её все автоматически.