Saltar al contenido principal
Login

Drupal 11 headless con Next.js: la guía completa de implementación

Denys Kravchenko profile picture
por Denys Kravchenko
nucleus

Quieres el modelado de contenido y la potencia editorial de Drupal, pero también un frontend en React ultrarrápido, transiciones con sensación de app y la libertad de publicar el mismo contenido hoy en un sitio web y mañana en una app móvil. Esa es la promesa headless. Esta guía cuenta la historia completa: cómo convertir Drupal 11 en un backend seguro y orientado a API, y combinarlo con un frontend Next.js 16 App Router que se encarga del inicio de sesión, la edición, los medios y los bloques de contenido complejos, y luego cómo sumar un segundo frontend que comparte la misma identidad.

Está construida a partir de un proyecto real de Drupal 11 que corre en local con DDEV y está preparado para Pantheon. Cada comando que ves aquí se ejecutó. Cada código de error es uno que vimos de verdad.

A glowing abstract sphere woven from dots and lines radiating outward
Un único núcleo de contenido, muchas conexiones: la idea headless en una sola imagen. Foto de Growtika en Unsplash.

¿De verdad te conviene ir headless?

Antes de construir, la pregunta honesta. Drupal headless resuelve problemas reales para ciertos equipos y crea problemas nuevos para otros. Apuesta por el modelo desacoplado cuando necesites entrega omnicanal, un frontend moderno en JS, una experiencia con sensación de app o una separación limpia entre lo editorial y la presentación. Quédate con el enfoque tradicional cuando un solo sitio web sea todo el producto y tu equipo se mueva dentro de la capa de temas de Drupal.

Regla práctica: ajusta la arquitectura a una necesidad operativa, no a una moda. Si tu contenido tiene que llegar a más de una "cabeza" (web, móvil, kiosco, un agente de IA), headless vale la pena. Si no, solo estás sumando piezas móviles sin ninguna ganancia.

Aquí está el balance de un vistazo.

AspectoDrupal tradicionalDrupal 11 headless + Next.js
FrontendTema Twig, renderizado en servidorReact/Next.js, App Router, caché en el edge
Alcance del contenidoUn solo sitio webWeb + apps + IoT (contenido como datos)
Techo de rendimientoBueno con cachéSensación de app, control fino de la caché
Complejidad de autenticaciónSesiones integradasTú conectas OAuth2 + JWT por tu cuenta
Vista previa editorialNativaRequiere montar el Draft Mode
Habilidades del equipoPHP/TwigPHP y TypeScript/React
Superficie operativaUna sola app que mantenerVarias apps, CORS, tokens, revalidación

Si todavía estás sopesando la decisión, nuestro análisis sobre las ventajas de usar Drupal como CMS headless profundiza en el tema.

El stack

HerramientaVersiónRol
Drupal11 (PHP 8.3)Backend de contenido e identidad
DDEVúltima versiónEntorno local
Next.js16.2.9 (App Router, Cache Components)Frontend
React19.2 (React Compiler)Interfaz de usuario
next-drupal^2.0Cliente JSON:API
Auth.js (next-auth)^4.24Sesión + inicio de sesión
Tiptap3.26Editor enriquecido
Bun1.3+Runtime + gestor de paquetes

Asegurar Drupal 11 como backend de API

Activar JSON:API

Drupal incluye JSON:API en el core: actívalo y al instante obtienes cada tipo de contenido, campo y relación como un endpoint RESTful, con filtrado, ordenación y paginación ya incorporados.

bash
ddev drush en jsonapi serialization -y
ddev drush cr

Suma los dos módulos contrib que lo dejan listo para producción:

bash
ddev composer require drupal/jsonapi_extras:^3.0 drupal/decoupled_router:^2.0
ddev drush en jsonapi_extras decoupled_router -y
ddev drush cr

decoupled_router devuelve URLs completamente cualificadas al resolver rutas de entidades, justo lo que un frontend Next.js necesita para traducir un slug en un nodo.

Configurar CORS

Copia el archivo de servicios y agrega un bloque CORS dentro de parameters en web/sites/default/services.yml:

yaml
cors.config:
  enabled: true
  allowedHeaders: ['*']
  allowedMethods: ['GET', 'POST', 'PATCH', 'DELETE', 'OPTIONS']
  allowedOrigins: ['*']
  maxAge: 86400
  supportsCredentials: false

Nota para producción: allowedOrigins: ['*'] está bien para el desarrollo local. En producción, restríngelo al dominio de tu frontend. Una política CORS abierta sobre una API autenticada es una exposición de seguridad real, no un detalle de estilo.

Protegerlo con OAuth2

Instala Simple OAuth y el complemento para el flujo de contraseña:

bash
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

Haz que las rutas de las claves OAuth dependan del entorno, para que la misma configuración funcione en DDEV y en Pantheon. Agrega esto a settings.php:

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';
}

Luego crea un Consumer de OAuth (/admin/config/services/consumer/add) con el grant Resource Owner Password Credentials. Ten algo presente desde el principio: los consumers son entidades de contenido, no configuración: viven en la base de datos y no se exportan a config/sync, así que los vuelves a crear en cada entorno en lugar de esperar que una importación de configuración los traiga consigo.

Prueba directamente el endpoint del token:

bash
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="

Una respuesta limpia te entrega un token Bearer con un expires_in corto. Ese token es la llave de cada escritura autenticada. Cuando listes colecciones a mano, agrega --globoff a tu curl para que la shell no intente expandir los corchetes de page[limit].

Luminous data strings tracing across a dark circuit-like surface
JSON:API junto con OAuth2 convierte a Drupal en un flujo de datos protegido por tokens. Foto de Luke Jones en Unsplash.

Registro de usuarios e inicio de sesión con Google

El registro autogestionado usa el endpoint REST de Drupal, llamado sin token: solo los usuarios anónimos pueden registrarse a sí mismos, así que enviar una cabecera Authorization aquí devuelve un 403:

bash
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!"}
  }'

Un solo ajuste decide la forma de la petición: si "Require email verification" está activado, omite pass y Drupal envía por correo un enlace de inicio de sesión; si está desactivado, incluye pass y el usuario queda activo de inmediato. Si envías una contraseña con la verificación activada, obtendrás un 422, así que ajusta el cuerpo de la petición al ajuste y la llamada funciona sin más.

Para el inicio de sesión social, Drupal 11 usa Social Auth Google ^4.0 (no la 2.x, que apunta a versiones más antiguas de Drupal). Registra /user/login/google y ejecuta el intercambio OAuth del lado del servidor.

Atención: la interfaz de la consola de Google cambió en 2025: la antigua "pantalla de consentimiento de OAuth" ahora es la Google Auth Platform, con secciones de Branding, Audience y Clients. Copia el URI de redirección directamente desde el campo de solo lectura de Drupal; debe coincidir carácter por carácter, incluido el https:// y la ruta final, o Google responderá con redirect_uri_mismatch.

Construir el frontend Next.js (Studio)

Genera el frontend editorial, que llamaremos Studio, con Bun y luego agrega las dos librerías que se comunican con Drupal:

bash
bun create next-app@latest studio   # TypeScript, Biome, React Compiler, Tailwind 4, src/, App Router
cd studio
bun add next-drupal next-auth

Auth.js usa un proveedor de credenciales que intercambia usuario y contraseña por un token Bearer de Drupal en /oauth/token, y luego guarda el token y su expiración en una sesión JWT:

tsx
21 lines
// src/auth.ts (authorize, recortado)
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,
};

Las lecturas ocurren por ruta. Una ruta catch-all resuelve cualquier slug a un nodo de Drupal con translatePath, envuelta en los Cache Components de Next.js para poder revalidarla bajo demanda:

tsx
// 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);
}

Cuando el contenido cambia en Drupal, el módulo de Next.js hace un POST a /api/revalidate con un secreto compartido, y la ruta revalida por etiqueta. Si estás afinando el backend que lo alimenta, nuestra guía de optimización de Drupal 11 para DDEV y Pantheon aborda el escalado de local a producción.

Recordatorio de seguridad: DDEV sirve HTTPS con un certificado de confianza local, y Node no confía en él de forma predeterminada. Apunta Node al CA de mkcert en tu script dev (NODE_EXTRA_CA_CERTS="$(mkcert -CAROOT)/rootCA.pem") en lugar de desactivar la verificación TLS; de lo contrario, el fetch del lado del servidor hacia Drupal falla con UNABLE_TO_VERIFY_LEAF_SIGNATURE.

¿Necesitas un experto en Drupal?

Echo Flow ayuda a empresas canadienses con ingeniería Drupal de nivel empresarial.

Control de acceso real, no solo "tiene un token"

Tener un token válido no es lo mismo que tener permiso para escribir. Studio sondea el acceso de escritura de JSON:API haciendo un POST de un artículo vacío y leyendo el estado: un editor con permiso obtiene 422 (tu payload es inválido, pero puedes escribir), y un usuario sin permiso obtiene 403. En ningún caso se crea contenido.

tsx
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 = permitido, 403 = prohibido
}

Esta misma capa agrega el inicio de sesión directo con Google desde el frontend (el usuario nunca ve la URL de Drupal), la renovación silenciosa del token mediante el grant refresh_token y la vista previa con Draft Mode para revisiones sin publicar.

Un editor al estilo Notion que sigue siendo headless

Aquí es donde el proyecto se gana su lugar. El simple <textarea> se convierte en un editor Tiptap con un menú de barra diagonal / y atajos de Markdown. Y lo más importante: no hay una nueva API de guardado, el editor escribe en un input oculto que la acción de servidor existente ya lee.

El cuerpo del contenido pasa entonces de Markdown a HTML para poder llevar <figure> + <figcaption> + alineación + una referencia a un medio de Drupal. Las imágenes se suben como entidades media--image reales y reutilizables, y una única hoja de estilos compartida .rich-content renderiza tanto el editor como la página publicada, de modo que las dos nunca se desvíen.

La recompensa es contenido componible: bloques al nivel de Paragraphs (tarjetas, llamadas de atención, columnas) que un editor coloca donde quiera pulsando /, todo gestionado por un único registro.

Interconnected 3D cubes floating on a black surface
Un registro de bloques componibles: coloca uno en cualquier sitio, como encajar cubos entre sí. Foto de Shubham Dhage en Unsplash.

El registro es la fuente de verdad: una sola entrada define las props de un bloque, su View compartida (usada tanto por el editor como por la página) y un Form opcional dentro del editor:

tsx
export type BlockDefinition<P = Record<string, unknown>> = {
  type: string;
  title: string;
  icon: ReactNode;
  keywords?: string[];
  defaultProps: P;
  View: (args: { props: P }) => ReactNode;        // render compartido edición + página
  Form?: (args: { props: P; onChange: (next: P) => void }) => ReactNode;
};

Agrega un bloque nuevo (un gráfico, una tarjeta de precios, columnas) y el menú de barra diagonal, la vista previa en el editor, la serialización y el renderizado publicado lo recogen todos de forma automática. Un nodo genérico de Tiptap serializa cada bloque en un marcador ordenado que el renderizador de la página vuelve a mapear a un componente de React:

html
<div data-block="card" data-props='{"title":"Pricing","body":"…","cta":"/signup"}'></div>

La decisión de arquitectura que importa: mantén la estructura del bloque en la app de Next.js y persístela como metadatos ligeros en el cuerpo de Drupal. Es DRY (un componente renderiza en todas partes) y mantiene a Drupal como un simple almacén. Recurre a Paragraphs de Drupal o a referencias de entidad solo cuando un bloque deba compartirse o consultarse del lado del servidor a través de muchos nodos.

Un segundo frontend sobre un único proveedor de identidad (Reel)

El paso final demuestra que el modelo escala: un segundo frontend (un hub de video que llamaremos Reel) reutiliza el mismo proveedor de identidad de Drupal. Ponle el nombre correcto y el diseño cae por su propio peso. Esto son dos clientes OAuth 2.0 que comparten un único servidor de autorización, ni microservicios ni SSO.

Las lecciones que costaron sudor son las más valiosas de toda la construcción:

  • Probamos authorization-code + PKCE (el camino correcto según el estándar) y volvimos al flujo de contraseña. El cambio de puertos, la confianza TLS de DDEV, la autenticación de cliente confidencial y la falta de un formulario dentro de la app se convirtieron en una muerte por mil cortes. El inicio de sesión por contraseña de Studio ya funcionaba y se podía replicar en una tarde: un cambio deliberado y documentado de pureza del estándar a cambio de paridad y rapidez.
  • Choque de cookies en el mismo origen. Dos frontends en localhost:3000 pisan la cookie de sesión del otro y lanzan JWT_SESSION_ERROR. La solución es ponerle un espacio de nombres a las cookies de Reel (por ejemplo reel.session-token) para que las dos nunca choquen.
  • sub es el uid entero, no el UUID. El /oauth/userinfo de Simple OAuth devuelve el uid interno; JSON:API direcciona a los usuarios por UUID. Resuélvelo una sola vez al iniciar sesión.
  • Cloudinary por usuario (trae el tuyo). Cada usuario aporta su propio CLOUDINARY_URL, cifrado en la app con AES-256-GCM antes de que llegue siquiera a Drupal, de modo que Drupal solo almacena el texto cifrado, restringido a su propietario mediante Field Permissions. Pasa las credenciales en cada llamada; nunca modifiques un cloudinary.config() global, que no es seguro bajo concurrencia.

El trabajo interesante estuvo en las costuras de la integración, no en las funcionalidades: el espacio de nombres de las cookies, la trampa del uid frente al UUID, el certificado de DDEV y el guardado que revoca el token. Esas son las partes que vale la pena recordar.

Hacia dónde llevarlo después

Ahora tienes una API segura de Drupal 11, un frontend editorial con autenticación real y un editor de bloques al estilo Notion, y un segundo frontend sobre el mismo proveedor de identidad. Pasos siguientes naturales:

  • Restringe los orígenes de CORS y cambia el usuario administrator de desarrollo por un rol acotado de API Client.
  • Pasa el cifrado de secretos de una sola clave de entorno al cifrado por sobre con KMS (el prefijo de texto cifrado v1: ya está ahí para eso).
  • Agrega validación de esquema (por ejemplo, Zod) para las props de los bloques.
  • Promueve a Paragraphs de Drupal el bloque raro que necesite compartirse o consultarse.

Headless no sale gratis: cambias simplicidad operativa por alcance y libertad en el frontend. Pero cuando de verdad necesitas más de una cabeza, este stack ofrece un sistema rápido, seguro y cómodo para el editor que trata tu contenido por lo que realmente es: datos puros y portables. Si quieres llevar el lado de Drupal más lejos, consulta nuestra guía práctica de desarrollo de entidades personalizadas en Drupal 11.2.

A cluster of glowing spheres linked together in 3D space
Un único proveedor de identidad, muchas apps conectadas: contenido entregado como datos en cada canal. Foto de Logan Voss en Unsplash.

Preguntas frecuentes

¿Vale la pena Drupal 11 headless?

Depende del alcance. Si tu contenido tiene que servir a más de un frontend (un sitio web más una app móvil, un kiosco o un agente de IA), el modelo desacoplado se paga solo en flexibilidad y velocidad del frontend. Si un único sitio web es todo el producto, Drupal tradicional es más simple y más barato de operar.

¿Tengo que usar el flujo de contraseña de OAuth?

No, pero es la opción pragmática para un frontend de confianza y de primera parte. Authorization-code + PKCE es el patrón correcto según el estándar y te da inicio de sesión único entre apps de forma gratuita; solo que son más piezas que conectar contra un backend local. El flujo de contraseña te da un inicio de sesión dentro de la app que funciona rápido, con un camino de actualización claro hacia PKCE más adelante.

¿Por qué mi petición de registro devuelve 403?

Estás enviando una cabecera Authorization. Solo los usuarios anónimos pueden registrarse a sí mismos, así que la llamada de registro debe salir sin ningún token Bearer adjunto.

¿Por qué Node no confía en mi backend HTTPS de DDEV?

DDEV usa un certificado de mkcert de confianza local que Node no reconoce, lo que se manifiesta como UNABLE_TO_VERIFY_LEAF_SIGNATURE. Apunta Node al CA raíz de mkcert con NODE_EXTRA_CA_CERTS="$(mkcert -CAROOT)/rootCA.pem" en tu script de desarrollo en lugar de desactivar la verificación TLS.

¿Por qué dos frontends en localhost rompen el inicio de sesión del otro?

Comparten los mismos nombres de cookie de sesión por defecto en un mismo origen, y ninguna app puede descifrar la sesión de la otra, así que obtienes JWT_SESSION_ERROR. Dale a cada app sus propios nombres de cookie (por ejemplo reel.session-token) y conviven sin problemas.

Mi consumer de OAuth desapareció tras importar la configuración, ¿por qué?

Los consumers son entidades de contenido, no configuración, así que no forman parte de config/sync y una importación de configuración no los recreará. Vuelve a agregar el consumer en cada entorno.

¿Puedo agregar mis propios bloques de contenido al editor?

Sí. Agrega una entrada al registro de bloques (sus props, una View compartida y un Form opcional dentro del editor) y el menú de barra diagonal, la vista previa en vivo, la serialización y el renderizado publicado lo recogen todos de forma automática.